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 2020/06/17 02:56:10 UTC
[incubator-apisix-dashboard] 02/02: feat: merged next
This is an automated email from the ASF dual-hosted git repository.
juzhiyuan pushed a commit to branch feat-master
in repository https://gitbox.apache.org/repos/asf/incubator-apisix-dashboard.git
commit 67bfaff866718ab1a6130eb6e4537582d4a519b6
Author: juzhiyuan <jj...@gmail.com>
AuthorDate: Wed Jun 17 10:55:17 2020 +0800
feat: merged next
---
.asf.yaml | 37 +
.dockerignore | 1 +
.editorconfig | 16 +
.eslintignore | 4 +
.eslintrc.js | 8 +
.github/workflows/api_ut.yml | 19 +
.gitignore | 43 +
.prettierignore | 21 +
.prettierrc.js | 5 +
.stylelintrc.js | 5 +
Dockerfile | 19 +
README-dashboard.md | 47 +
README.md | 10 +
api/Dockerfile | 49 +
api/README.md | 22 +
api/build.sh | 30 +
api/conf.json | 19 +
api/conf/conf.go | 104 +
api/conf/conf.json | 19 +
api/conf/mysql.go | 46 +
api/docker-compose.yml | 9 +
api/errno/error.go | 98 +
api/filter/cors.go | 33 +
api/filter/logging.go | 84 +
api/filter/recover.go | 120 +
api/filter/request_id.go | 42 +
api/go.mod | 15 +
api/go.sum | 89 +
api/log/log.go | 147 +
api/main.go | 65 +
api/route/healthz.go | 37 +
api/route/plugin.go | 58 +
api/route/route.go | 242 +
api/route/ssl.go | 153 +
api/script/db/schema.sql | 31 +
api/service/base.go | 48 +
api/service/plugin.go | 61 +
api/service/route.go | 560 +
api/service/route_test.go | 139 +
api/service/ssl.go | 300 +
api/utils/copy.go | 41 +
api/utils/http.go | 113 +
compose/README.md | 56 +
compose/apisix_conf/config.yaml | 137 +
compose/docker-compose.yml | 124 +
compose/grafana_conf/config/grafana.ini | 756 +
.../dashboards/apisix_http_prometheus.json | 956 +
.../grafana_conf/provisioning/dashboards/all.yaml | 11 +
.../grafana_conf/provisioning/datasources/all.yaml | 9 +
compose/manager_conf/build.sh | 20 +
compose/pics/grafana_1.png | Bin 0 -> 57816 bytes
compose/pics/grafana_2.png | Bin 0 -> 127932 bytes
compose/pics/grafana_3.png | Bin 0 -> 103384 bytes
compose/pics/grafana_4.png | Bin 0 -> 82558 bytes
compose/pics/grafana_5.png | Bin 0 -> 105366 bytes
compose/pics/grafana_6.png | Bin 0 -> 179132 bytes
compose/pics/login.png | Bin 0 -> 84958 bytes
compose/prometheus_conf/prometheus.yml | 23 +
config/config.ts | 48 +
config/defaultSettings.ts | 20 +
config/proxy.ts | 30 +
config/routes.ts | 76 +
docker/nginx.conf | 21 +
jest.config.js | 9 +
jsconfig.json | 10 +
mock/notices.ts | 105 +
mock/route.ts | 5 +
mock/user.ts | 154 +
netlify.toml | 13 +
package.json | 109 +
public/favicon.png | Bin 0 -> 85376 bytes
public/home_bg.png | Bin 0 -> 203330 bytes
public/icons/icon-128x128.png | Bin 0 -> 1329 bytes
public/icons/icon-192x192.png | Bin 0 -> 1856 bytes
public/icons/icon-512x512.png | Bin 0 -> 5082 bytes
public/pro_icon.svg | 1 +
src/access.ts | 6 +
src/app.tsx | 92 +
src/assets/logo.svg | 21 +
src/components/Footer/index.tsx | 17 +
src/components/HeaderDropdown/index.less | 16 +
src/components/HeaderDropdown/index.tsx | 19 +
src/components/NoticeIcon/NoticeList.less | 103 +
src/components/NoticeIcon/NoticeList.tsx | 113 +
src/components/NoticeIcon/index.less | 31 +
src/components/NoticeIcon/index.tsx | 124 +
src/components/PageLoading/index.tsx | 5 +
src/components/PluginForm/PluginForm.tsx | 192 +
src/components/PluginForm/README.md | 9 +
src/components/PluginForm/data.ts | 100 +
src/components/PluginForm/index.ts | 3 +
src/components/PluginForm/locales/en-US.ts | 152 +
src/components/PluginForm/locales/zh-CN.ts | 152 +
src/components/PluginForm/service.ts | 5 +
src/components/PluginForm/transformer.ts | 90 +
src/components/PluginForm/typing.d.ts | 64 +
src/components/PluginModal/index.tsx | 32 +
src/components/RightContent/AvatarDropdown.tsx | 94 +
src/components/RightContent/index.less | 82 +
src/components/RightContent/index.tsx | 51 +
src/e2e/__mocks__/antd-pro-merge-less.js | 1 +
src/e2e/baseLayout.e2e.js | 57 +
src/global.less | 69 +
src/global.tsx | 83 +
src/locales/en-US.ts | 23 +
src/locales/en-US/component.ts | 39 +
src/locales/en-US/globalHeader.ts | 17 +
src/locales/en-US/menu.ts | 60 +
src/locales/en-US/pwa.ts | 6 +
src/locales/en-US/setting.ts | 4 +
src/locales/en-US/settingDrawer.ts | 31 +
src/locales/zh-CN.ts | 23 +
src/locales/zh-CN/component.ts | 39 +
src/locales/zh-CN/globalHeader.ts | 17 +
src/locales/zh-CN/menu.ts | 61 +
src/locales/zh-CN/pwa.ts | 6 +
src/locales/zh-CN/setting.ts | 12 +
src/locales/zh-CN/settingDrawer.ts | 31 +
src/manifest.json | 22 +
src/pages/404.tsx | 18 +
src/pages/Metrics/Metrics.tsx | 54 +
src/pages/Routes/Create.less | 112 +
src/pages/Routes/Create.tsx | 223 +
src/pages/Routes/List.tsx | 82 +
.../Routes/components/ActionBar/ActionBar.tsx | 46 +
src/pages/Routes/components/ActionBar/index.ts | 1 +
.../Routes/components/CreateStep3/CreateStep3.tsx | 96 +
.../Routes/components/CreateStep3/PluginCard.tsx | 26 +
.../Routes/components/CreateStep3/PluginDrawer.tsx | 73 +
src/pages/Routes/components/CreateStep3/index.ts | 1 +
.../Routes/components/CreateStep4/CreateStep4.tsx | 35 +
src/pages/Routes/components/CreateStep4/index.ts | 1 +
src/pages/Routes/components/PanelSection/index.tsx | 16 +
.../Routes/components/ResultView/ResultView.tsx | 24 +
src/pages/Routes/components/ResultView/index.ts | 1 +
.../Routes/components/Step1/MatchingRulesView.tsx | 214 +
src/pages/Routes/components/Step1/MetaView.tsx | 27 +
.../Routes/components/Step1/RequestConfigView.tsx | 201 +
src/pages/Routes/components/Step1/index.tsx | 41 +
.../components/Step2/HttpHeaderRewriteView.tsx | 166 +
.../Routes/components/Step2/RequestRewriteView.tsx | 174 +
src/pages/Routes/components/Step2/index.tsx | 20 +
src/pages/Routes/constants.ts | 61 +
src/pages/Routes/service.ts | 24 +
src/pages/Routes/transform.ts | 176 +
src/pages/Routes/typing.d.ts | 125 +
src/pages/Setting/Setting.tsx | 102 +
src/pages/Setting/index.ts | 2 +
src/pages/Setting/service.ts | 7 +
src/pages/Setting/style.less | 109 +
src/pages/Setting/typingd.d.ts | 9 +
src/pages/document.ejs | 193 +
src/pages/ssl/Create.less | 101 +
src/pages/ssl/Create.tsx | 65 +
src/pages/ssl/List.tsx | 110 +
src/pages/ssl/components/CertificateForm/index.tsx | 75 +
.../ssl/components/CertificateUploader/index.tsx | 97 +
src/pages/ssl/components/Step1/index.tsx | 104 +
src/pages/ssl/components/Step2/index.tsx | 32 +
src/pages/ssl/components/Step3/index.tsx | 36 +
src/pages/ssl/service.ts | 78 +
src/pages/ssl/typing.d.ts | 18 +
src/service-worker.js | 70 +
src/services/API.d.ts | 24 +
src/services/login.ts | 20 +
src/services/user.ts | 23 +
src/transforms/global.ts | 26 +
src/typings.d.ts | 40 +
tests/PuppeteerEnvironment.js | 41 +
tests/beforeTest.js | 39 +
tests/getBrowser.js | 45 +
tests/run-tests.js | 52 +
tsconfig.json | 26 +
yarn.lock | 18920 +++++++++++++++++++
174 files changed, 30378 insertions(+)
diff --git a/.asf.yaml b/.asf.yaml
new file mode 100644
index 0000000..fe73d20
--- /dev/null
+++ b/.asf.yaml
@@ -0,0 +1,37 @@
+#
+# 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.
+#
+
+github:
+ description: Dashboard for Apache APISIX
+ homepage: https://apisix.apache.org/
+ labels:
+ - dashboard
+ - api
+ - api-management
+ - apisix
+ - devops
+ - docker
+
+ enabled_merge_buttons:
+ squash: true
+ merge: false
+ rebase: false
+
+ notifications:
+ commits: notifications@apisix.apache.org
+ issues: notifications@apisix.apache.org
+ pullrequests: notifications@apisix.apache.org
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..3c3629e
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1 @@
+node_modules
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..7e3649a
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,16 @@
+# http://editorconfig.org
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.md]
+trim_trailing_whitespace = false
+
+[Makefile]
+indent_style = tab
diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 0000000..16116a2
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1,4 @@
+/lambda/
+/scripts
+/config
+.history
\ No newline at end of file
diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 0000000..b882c20
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,8 @@
+module.exports = {
+ extends: [require.resolve('@umijs/fabric/dist/eslint')],
+ globals: {
+ ANT_DESIGN_PRO_ONLY_DO_NOT_USE_IN_YOUR_PRODUCTION: true,
+ page: true,
+ REACT_APP_ENV: true,
+ },
+};
diff --git a/.github/workflows/api_ut.yml b/.github/workflows/api_ut.yml
new file mode 100644
index 0000000..6380353
--- /dev/null
+++ b/.github/workflows/api_ut.yml
@@ -0,0 +1,19 @@
+name: API unit test
+
+on:
+ push:
+ branches:
+ - master
+ - manager
+ pull_request:
+ branches:
+ - master
+ - next
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+ - name: use docker-compose in api
+ run: cd ./api && docker-compose up
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9caa192
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,43 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+**/node_modules
+# roadhog-api-doc ignore
+/src/utils/request-temp.js
+_roadhog-api-doc
+
+# production
+/dist
+/.vscode
+
+# misc
+.DS_Store
+npm-debug.log*
+yarn-error.log
+
+/coverage
+.idea
+package-lock.json
+*bak
+.vscode
+
+# visual studio code
+.history
+*.log
+functions/*
+.temp/**
+
+# umi
+.umi
+.umi-production
+
+# screenshot
+screenshot
+.firebase
+.eslintcache
+
+build
+
+/compose/**/*.log
+/compose/**/nginx.pid
+/compose/etcd_data
\ No newline at end of file
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..87715a7
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,21 @@
+**/*.svg
+package.json
+.umi
+.umi-production
+/dist
+.dockerignore
+.DS_Store
+.eslintignore
+*.png
+*.toml
+docker
+.editorconfig
+Dockerfile*
+.gitignore
+.prettierignore
+LICENSE
+.eslintcache
+*.lock
+yarn-error.log
+.history
+CNAME
diff --git a/.prettierrc.js b/.prettierrc.js
new file mode 100644
index 0000000..7b597d7
--- /dev/null
+++ b/.prettierrc.js
@@ -0,0 +1,5 @@
+const fabric = require('@umijs/fabric');
+
+module.exports = {
+ ...fabric.prettier,
+};
diff --git a/.stylelintrc.js b/.stylelintrc.js
new file mode 100644
index 0000000..c203078
--- /dev/null
+++ b/.stylelintrc.js
@@ -0,0 +1,5 @@
+const fabric = require('@umijs/fabric');
+
+module.exports = {
+ ...fabric.stylelint,
+};
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..72ac8d3
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,19 @@
+# phase-build
+FROM node:12-alpine as builder
+
+WORKDIR /usr/src/app/
+USER root
+
+COPY package.json /usr/src/app/
+RUN yarn
+
+COPY . /usr/src/app/
+RUN yarn build && rm -rf /usr/src/app/node_modules
+
+# phase-run
+FROM nginx:1.16-alpine
+
+COPY ./docker/nginx.conf /etc/nginx/conf.d/default.conf
+COPY --from=builder /usr/src/app/dist /usr/share/nginx/html/dashboard
+
+EXPOSE 80
diff --git a/README-dashboard.md b/README-dashboard.md
new file mode 100644
index 0000000..74f8748
--- /dev/null
+++ b/README-dashboard.md
@@ -0,0 +1,47 @@
+# READMD for Dashboard
+
+This project is initialized with [Ant Design Pro](https://pro.ant.design). Follow is the quick guide for how to use.
+
+## Environment Prepare
+
+1. Make sure you have `Node.js` installed on your machine.
+2. Install [yarn](https://yarnpkg.com/).
+3. Install `node_modules`:
+
+```bash
+$ yarn
+```
+
+### Start project
+
+```bash
+yarn start:no-mock
+```
+
+### Build project
+
+```bash
+yarn build
+```
+
+### Check code style
+
+```bash
+yarn lint
+```
+
+You can also use script to auto fix some lint error:
+
+```bash
+yarn lint:fix
+```
+
+### Test code
+
+```bash
+yarn test
+```
+
+## More
+
+You can view full document on our [official website](https://pro.ant.design). And welcome any feedback in our [github](https://github.com/ant-design/ant-design-pro).
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0a82a39
--- /dev/null
+++ b/README.md
@@ -0,0 +1,10 @@
+# Apache APISIX Dashboard
+
+Dashboard for [Apache APISIX](https://github.com/apache/incubator-apisix-dashboard)
+
+## Deploy with Docker
+Please refer to [Deploy with Docker README](./compose/README.md)
+
+## More
+
+1. More infomation about the frontend Dashboard, please refer to [README for Dashboard](./README-dashboard.md)
diff --git a/api/Dockerfile b/api/Dockerfile
new file mode 100644
index 0000000..85092a1
--- /dev/null
+++ b/api/Dockerfile
@@ -0,0 +1,49 @@
+#
+# 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.
+#
+
+FROM golang:1.13.8 AS build-env
+
+WORKDIR /go/src/github.com/apisix/manager-api
+COPY . .
+RUN mkdir /root/manager-api \
+ && go env -w GOPROXY=https://goproxy.io,direct \
+ && export GOPROXY=https://goproxy.io \
+ && go build -o /root/manager-api/manager-api \
+ && mv /go/src/github.com/apisix/manager-api/build.sh /root/manager-api/ \
+ && mv /go/src/github.com/apisix/manager-api/conf.json /root/manager-api/ \
+ && rm -rf /go/src/github.com/apisix/manager-api \
+ && rm -rf /etc/localtime \
+ && ln -s /usr/share/zoneinfo/Hongkong /etc/localtime \
+ && dpkg-reconfigure -f noninteractive tzdata
+
+FROM alpine:3.11
+
+RUN mkdir /root/manager-api \
+ && apk update \
+ && apk add ca-certificates \
+ && update-ca-certificates \
+ && apk add --no-cache libc6-compat \
+ && echo "hosts: files dns" > /etc/nsswitch.conf \
+ && rm -rf /var/cache/apk/*
+
+
+WORKDIR /root/manager-api
+COPY --from=build-env /root/manager-api/* /root/manager-api/
+COPY --from=build-env /usr/share/zoneinfo/Hongkong /etc/localtime
+EXPOSE 8080
+RUN chmod +x ./build.sh
+CMD ["/root/manager-api/build.sh"]
diff --git a/api/README.md b/api/README.md
new file mode 100644
index 0000000..24befa6
--- /dev/null
+++ b/api/README.md
@@ -0,0 +1,22 @@
+<!--
+#
+# 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.
+#
+-->
+
+# manager-api
+
+This is a back-end project that the dashboard depends on, implemented through golang.
diff --git a/api/build.sh b/api/build.sh
new file mode 100644
index 0000000..086fe73
--- /dev/null
+++ b/api/build.sh
@@ -0,0 +1,30 @@
+#!/bin/sh
+#
+# 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.
+#
+
+pwd=`pwd`
+
+sed -i -e "s%#mysqlAddress#%`echo $MYSQL_SERVER_ADDRESS`%g" ${pwd}/conf.json
+sed -i -e "s%#mysqlUser#%`echo $MYSQL_USER`%g" ${pwd}/conf.json
+sed -i -e "s%#mysqlPWD#%`echo $MYSQL_PASSWORD`%g" ${pwd}/conf.json
+sed -i -e "s%#syslogAddress#%`echo $SYSLOG_HOST`%g" ${pwd}/conf.json
+sed -i -e "s%#apisixBaseUrl#%`echo $APISIX_BASE_URL`%g" ${pwd}/conf.json
+sed -i -e "s%#apisixApiKey#%`echo $APISIX_API_KEY`%g" ${pwd}/conf.json
+
+cd /root/manager-api
+exec ./manager-api
+
diff --git a/api/conf.json b/api/conf.json
new file mode 100644
index 0000000..31cbcce
--- /dev/null
+++ b/api/conf.json
@@ -0,0 +1,19 @@
+{
+ "conf": {
+ "mysql":{
+ "address": "#mysqlAddress#",
+ "user": "#mysqlUser#",
+ "password": "#mysqlPWD#",
+ "maxConns": 50,
+ "maxIdleConns": 25,
+ "maxLifeTime": 10
+ },
+ "syslog": {
+ "host": "#syslogAddress#"
+ },
+ "apisix": {
+ "base_url": "#apisixBaseUrl#",
+ "api_key": "#apisixApiKey#"
+ }
+ }
+}
diff --git a/api/conf/conf.go b/api/conf/conf.go
new file mode 100644
index 0000000..5819789
--- /dev/null
+++ b/api/conf/conf.go
@@ -0,0 +1,104 @@
+/*
+ * 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.
+ */
+package conf
+
+import (
+ "fmt"
+ "github.com/tidwall/gjson"
+ "io/ioutil"
+ "os"
+ "path/filepath"
+ "runtime"
+)
+
+const ServerPort = 8080
+const PROD = "prod"
+const BETA = "beta"
+const DEV = "dev"
+const LOCAL = "local"
+const confPath = "/root/manager-api/conf.json"
+const RequestId = "requestId"
+
+var (
+ ENV string
+ basePath string
+ ApiKey = "edd1c9f034335f136f87ad84b625c8f1"
+ BaseUrl = "http://127.0.0.1:9080/apisix/admin"
+)
+
+func init() {
+ setEnvironment()
+ initMysql()
+ initApisix()
+}
+
+func setEnvironment() {
+ if env := os.Getenv("ENV"); env == "" {
+ ENV = LOCAL
+ } else {
+ ENV = env
+ }
+ _, basePath, _, _ = runtime.Caller(1)
+}
+
+func configurationPath() string {
+ if ENV == LOCAL {
+ return filepath.Join(filepath.Dir(basePath), "conf.json")
+ } else {
+ return confPath
+ }
+}
+
+type mysqlConfig struct {
+ Address string
+ User string
+ Password string
+
+ MaxConns int
+ MaxIdleConns int
+ MaxLifeTime int
+}
+
+var MysqlConfig mysqlConfig
+
+func initMysql() {
+ filePath := configurationPath()
+ if configurationContent, err := ioutil.ReadFile(filePath); err != nil {
+ panic(fmt.Sprintf("fail to read configuration: %s", filePath))
+ } else {
+ configuration := gjson.ParseBytes(configurationContent)
+ mysqlConf := configuration.Get("conf.mysql")
+ MysqlConfig.Address = mysqlConf.Get("address").String()
+ MysqlConfig.User = mysqlConf.Get("user").String()
+ MysqlConfig.Password = mysqlConf.Get("password").String()
+ MysqlConfig.MaxConns = int(mysqlConf.Get("maxConns").Int())
+ MysqlConfig.MaxIdleConns = int(mysqlConf.Get("maxIdleConns").Int())
+ MysqlConfig.MaxLifeTime = int(mysqlConf.Get("maxLifeTime").Int())
+ }
+}
+
+func initApisix() {
+ filePath := configurationPath()
+ if configurationContent, err := ioutil.ReadFile(filePath); err != nil {
+ panic(fmt.Sprintf("fail to read configuration: %s", filePath))
+ } else {
+ configuration := gjson.ParseBytes(configurationContent)
+ apisixConf := configuration.Get("conf.apisix")
+ BaseUrl = apisixConf.Get("base_url").String()
+ ApiKey = apisixConf.Get("api_key").String()
+ }
+}
diff --git a/api/conf/conf.json b/api/conf/conf.json
new file mode 100644
index 0000000..95fe86e
--- /dev/null
+++ b/api/conf/conf.json
@@ -0,0 +1,19 @@
+{
+ "conf":{
+ "mysql":{
+ "address": "127.0.0.1:3306",
+ "user": "root",
+ "password": "123456",
+ "maxConns": 50,
+ "maxIdleConns": 25,
+ "maxLifeTime": 10
+ },
+ "syslog":{
+ "host": "localhost"
+ },
+ "apisix":{
+ "base_url": "http://127.0.0.1:9080/apisix/admin",
+ "api_key": "edd1c9f034335f136f87ad84b625c8f1"
+ }
+ }
+}
\ No newline at end of file
diff --git a/api/conf/mysql.go b/api/conf/mysql.go
new file mode 100644
index 0000000..62e0f9e
--- /dev/null
+++ b/api/conf/mysql.go
@@ -0,0 +1,46 @@
+/*
+ * 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.
+ */
+package conf
+
+import (
+ "fmt"
+ "github.com/jinzhu/gorm"
+ _ "github.com/jinzhu/gorm/dialects/mysql"
+ "time"
+)
+
+var db *gorm.DB
+
+func DB() *gorm.DB {
+ return db
+}
+
+// InitializeMysql creates mysql's *sqlDB instance
+func InitializeMysql() {
+ dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8&parseTime=True&loc=Local", MysqlConfig.User,
+ MysqlConfig.Password, MysqlConfig.Address, "manager")
+ if tmp, err := gorm.Open("mysql", dsn); err != nil {
+ panic(fmt.Sprintf("fail to connect to DB: %s for %s", err.Error(), dsn))
+ } else {
+ db = tmp
+ db.LogMode(true)
+ db.DB().SetMaxOpenConns(MysqlConfig.MaxConns)
+ db.DB().SetMaxIdleConns(MysqlConfig.MaxIdleConns)
+ db.DB().SetConnMaxLifetime(time.Duration(MysqlConfig.MaxLifeTime) * time.Minute)
+ }
+
+}
diff --git a/api/docker-compose.yml b/api/docker-compose.yml
new file mode 100644
index 0000000..79760d7
--- /dev/null
+++ b/api/docker-compose.yml
@@ -0,0 +1,9 @@
+version: "3"
+
+services:
+ manager:
+ image: golang:1.13.8
+ volumes:
+ - .:/go/src/github.com/apisix/manager-api
+ working_dir: /go/src/github.com/apisix/manager-api
+ command: go test -v github.com/apisix/manager-api/service
diff --git a/api/errno/error.go b/api/errno/error.go
new file mode 100644
index 0000000..20b7e24
--- /dev/null
+++ b/api/errno/error.go
@@ -0,0 +1,98 @@
+/*
+ * 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.
+ */
+package errno
+
+import (
+ "encoding/json"
+ "fmt"
+)
+
+type Message struct {
+ Code string
+ Msg string
+}
+
+var (
+ //AA 01 api-manager-api
+ SystemSuccess = Message{"010000", "success"}
+ SystemError = Message{"010001", "system error"}
+ BadRequestError = Message{Code: "010002", Msg: "Request format error"}
+ NotFoundError = Message{Code: "010003", Msg: "No resources found"}
+
+ //BB 01 config module
+ ConfEnvError = Message{"010101", "Environment variable not found: %s"}
+ ConfFilePathError = Message{"010102", "Error loading configuration file: %s"}
+
+ // BB 02 route module
+ RouteRequestError = Message{"010201", "Route request parameters are abnormal: %s"}
+ ApisixRouteCreateError = Message{"010202", "Failed to create APISIX route: %s"}
+ DBRouteCreateError = Message{"010203", "Route storage failure: %s"}
+ ApisixRouteUpdateError = Message{"010204", "Update APISIX routing failed: %s"}
+ ApisixRouteDeleteError = Message{"010205", "Failed to remove APISIX route: %s"}
+ DBRouteUpdateError = Message{"010206", "Route update failed: %s"}
+ DBRouteDeleteError = Message{"010207", "Route remove failed: %s"}
+
+ // 03 plugin module
+ ApisixPluginListError = Message{"010301", "List APISIX plugins failed: %s"}
+ ApisixPluginSchemaError = Message{"010301", "Find APISIX plugin schema failed: %s"}
+)
+
+type ManagerError struct {
+ TraceId string
+ Code string
+ Msg string
+ Data interface{}
+}
+
+// toString
+func (e *ManagerError) Error() string {
+ return e.Msg
+}
+
+func FromMessage(m Message, args ...interface{}) *ManagerError {
+ return &ManagerError{TraceId: "", Code: m.Code, Msg: fmt.Sprintf(m.Msg, args...)}
+}
+
+func (e *ManagerError) Response() map[string]interface{} {
+ return map[string]interface{}{
+ "code": e.Code,
+ "msg": e.Msg,
+ }
+}
+
+func (e *ManagerError) ItemResponse(data interface{}) map[string]interface{} {
+ return map[string]interface{}{
+ "code": e.Code,
+ "msg": e.Msg,
+ "data": data,
+ }
+}
+
+func (e *ManagerError) ListResponse(count, list interface{}) map[string]interface{} {
+ return map[string]interface{}{
+ "code": e.Code,
+ "msg": e.Msg,
+ "count": count,
+ "list": list,
+ }
+}
+
+func Success() []byte {
+ w := FromMessage(SystemSuccess).Response()
+ result, _ := json.Marshal(w)
+ return result
+}
diff --git a/api/filter/cors.go b/api/filter/cors.go
new file mode 100644
index 0000000..f2c7ac1
--- /dev/null
+++ b/api/filter/cors.go
@@ -0,0 +1,33 @@
+/*
+ * 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.
+ */
+package filter
+
+import "github.com/gin-gonic/gin"
+
+func CORS() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
+ c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
+ c.Writer.Header().Set("Access-Control-Allow-Headers", "*")
+ c.Writer.Header().Set("Access-Control-Allow-Methods", "*")
+ if c.Request.Method == "OPTIONS" {
+ c.AbortWithStatus(204)
+ return
+ }
+ c.Next()
+ }
+}
diff --git a/api/filter/logging.go b/api/filter/logging.go
new file mode 100644
index 0000000..22f5528
--- /dev/null
+++ b/api/filter/logging.go
@@ -0,0 +1,84 @@
+/*
+ * 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.
+ */
+package filter
+
+import (
+ "bytes"
+ "github.com/apisix/manager-api/errno"
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "time"
+)
+
+func RequestLogHandler() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ start, host, remoteIP, path, method := time.Now(), c.Request.Host, c.ClientIP(), c.Request.URL.Path, c.Request.Method
+ var val interface{}
+ if method == "GET" {
+ val = c.Request.URL.Query()
+ } else {
+ val, _ = c.GetRawData()
+ }
+ c.Set("requestBody", val)
+ uuid, _ := c.Get("X-Request-Id")
+
+ param, _ := c.Get("requestBody")
+ switch param.(type) {
+ case []byte:
+ param = string(param.([]byte))
+ default:
+ }
+
+ blw := &bodyLogWriter{body: bytes.NewBufferString(""), ResponseWriter: c.Writer}
+ c.Writer = blw
+ c.Next()
+ latency := time.Now().Sub(start) / 1000000
+ statusCode := c.Writer.Status()
+ respBody := blw.body.String()
+ if uuid == "" {
+ uuid = c.Writer.Header().Get("X-Request-Id")
+ }
+ var errs []string
+ for _, err := range c.Errors {
+ if e, ok := err.Err.(*errno.ManagerError); ok {
+ errs = append(errs, e.Error())
+ }
+ }
+ logger.WithFields(logrus.Fields{
+ "requestId": uuid,
+ "latency": latency,
+ "remoteIp": remoteIP,
+ "method": method,
+ "path": path,
+ "statusCode": statusCode,
+ "host": host,
+ "params": param,
+ "respBody": respBody,
+ "errMsg": errs,
+ }).Info("")
+ }
+}
+
+type bodyLogWriter struct {
+ gin.ResponseWriter
+ body *bytes.Buffer
+}
+
+func (w bodyLogWriter) Write(b []byte) (int, error) {
+ w.body.Write(b)
+ return w.ResponseWriter.Write(b)
+}
diff --git a/api/filter/recover.go b/api/filter/recover.go
new file mode 100644
index 0000000..433331f
--- /dev/null
+++ b/api/filter/recover.go
@@ -0,0 +1,120 @@
+/*
+ * 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.
+ */
+package filter
+
+import (
+ "bytes"
+ "fmt"
+ "github.com/apisix/manager-api/log"
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "io/ioutil"
+ "net/http"
+ "runtime"
+ "time"
+)
+
+var (
+ logger = log.GetLogger()
+ dunno = []byte("???")
+ centerDot = []byte("·")
+ dot = []byte(".")
+ slash = []byte("/")
+)
+
+func RecoverHandler() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ defer func() {
+ if err := recover(); err != nil {
+ uuid := c.Writer.Header().Get("X-Request-Id")
+ logger.WithFields(logrus.Fields{
+ "uuid": uuid,
+ })
+ stack := stack(3)
+ logger.Errorf("[Recovery] %s panic recovered:\n\n%s\n%s", timeFormat(time.Now()), err, stack)
+ c.AbortWithStatus(http.StatusInternalServerError)
+ }
+ }()
+ c.Next()
+ }
+}
+
+func WrapGo(f func(...interface{}), args ...interface{}) {
+ defer func() {
+ if err := recover(); err != nil {
+ stack := stack(3)
+ logger.Errorf("[Recovery] %s panic recovered:\n\n%s\n%s", timeFormat(time.Now()), err, stack)
+ }
+ }()
+ f(args...)
+}
+
+func stack(skip int) []byte {
+ buf := new(bytes.Buffer) // the returned data
+ // loaded file.
+ var lines [][]byte
+ var lastFile string
+ for i := skip; ; i++ {
+ pc, file, line, ok := runtime.Caller(i)
+ if !ok {
+ break
+ }
+ // Print this much at least. If we can't find the source, it won't show.
+ fmt.Fprintf(buf, "%s:%d (0x%x)\n", file, line, pc)
+ if file != lastFile {
+ data, err := ioutil.ReadFile(file)
+ if err != nil {
+ continue
+ }
+ lines = bytes.Split(data, []byte{'\n'})
+ lastFile = file
+ }
+ fmt.Fprintf(buf, "\t%s: %s\n", function(pc), source(lines, line))
+ }
+ return buf.Bytes()
+}
+
+// source returns a space-trimmed slice of the n'th line.
+func source(lines [][]byte, n int) []byte {
+ n-- // in stack trace, lines are 1-indexed but our array is 0-indexed
+ if n < 0 || n >= len(lines) {
+ return dunno
+ }
+ return bytes.TrimSpace(lines[n])
+}
+
+// function returns, if possible, the name of the function containing the PC.
+func function(pc uintptr) []byte {
+ fn := runtime.FuncForPC(pc)
+ if fn == nil {
+ return dunno
+ }
+ name := []byte(fn.Name())
+ if lastslash := bytes.LastIndex(name, slash); lastslash >= 0 {
+ name = name[lastslash+1:]
+ }
+ if period := bytes.Index(name, dot); period >= 0 {
+ name = name[period+1:]
+ }
+ name = bytes.Replace(name, centerDot, dot, -1)
+ return name
+}
+
+func timeFormat(t time.Time) string {
+ var timeString = t.Format("2006/01/02 - 15:04:05")
+ return timeString
+}
diff --git a/api/filter/request_id.go b/api/filter/request_id.go
new file mode 100644
index 0000000..2992869
--- /dev/null
+++ b/api/filter/request_id.go
@@ -0,0 +1,42 @@
+/*
+ * 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.
+ */
+package filter
+
+import (
+ "github.com/gin-gonic/gin"
+ "github.com/satori/go.uuid"
+)
+
+func RequestId() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // Check for incoming header, use it if exists
+ requestId := c.Request.Header.Get("X-Request-Id")
+
+ // Create request id with UUID4
+ if requestId == "" {
+ u4 := uuid.NewV4()
+ requestId = u4.String()
+ }
+
+ // Expose it for use in the application
+ c.Set("X-Request-Id", requestId)
+
+ // Set X-Request-Id header
+ c.Writer.Header().Set("X-Request-Id", requestId)
+ c.Next()
+ }
+}
diff --git a/api/go.mod b/api/go.mod
new file mode 100644
index 0000000..45372ec
--- /dev/null
+++ b/api/go.mod
@@ -0,0 +1,15 @@
+module github.com/apisix/manager-api
+
+go 1.13
+
+require (
+ github.com/gin-contrib/pprof v1.3.0
+ github.com/gin-gonic/gin v1.6.3
+ github.com/go-sql-driver/mysql v1.5.0
+ github.com/jinzhu/gorm v1.9.12
+ github.com/satori/go.uuid v1.2.0
+ github.com/sirupsen/logrus v1.6.0
+ github.com/stretchr/testify v1.4.0
+ github.com/tidwall/gjson v1.6.0
+ gopkg.in/resty.v1 v1.12.0
+)
diff --git a/api/go.sum b/api/go.sum
new file mode 100644
index 0000000..9e3e3e3
--- /dev/null
+++ b/api/go.sum
@@ -0,0 +1,89 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
+github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
+github.com/gin-contrib/pprof v1.3.0 h1:G9eK6HnbkSqDZBYbzG4wrjCsA4e+cvYAHUZw6W+W9K0=
+github.com/gin-contrib/pprof v1.3.0/go.mod h1:waMjT1H9b179t3CxuG1cV3DHpga6ybizwfBaM5OXaB0=
+github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.6.2/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
+github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
+github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
+github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
+github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
+github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
+github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
+github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY=
+github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
+github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
+github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
+github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
+github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I=
+github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/jinzhu/gorm v1.9.12 h1:Drgk1clyWT9t9ERbzHza6Mj/8FY/CqMyVzOiHviMo6Q=
+github.com/jinzhu/gorm v1.9.12/go.mod h1:vhTjlKSJUTWNtcbQtrMBFCxy7eXTzeCAzfL5fBZT/Qs=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
+github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8=
+github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
+github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
+github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
+github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
+github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
+github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
+github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
+github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
+github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I=
+github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/tidwall/gjson v1.6.0 h1:9VEQWz6LLMUsUl6PueE49ir4Ka6CzLymOAZDxpFsTDc=
+github.com/tidwall/gjson v1.6.0/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls=
+github.com/tidwall/match v1.0.1 h1:PnKP62LPNxHKTwvHHZZzdOAOCtsJTjo6dZLCwpKm5xc=
+github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E=
+github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4=
+github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
+github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
+github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
+github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
+github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/resty.v1 v1.12.0 h1:CuXP0Pjfw9rOuY6EP+UvtNvt5DSqHpIxILZKT/quCZI=
+gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/api/log/log.go b/api/log/log.go
new file mode 100644
index 0000000..c1645e8
--- /dev/null
+++ b/api/log/log.go
@@ -0,0 +1,147 @@
+/*
+ * 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.
+ */
+package log
+
+import (
+ "bufio"
+ "fmt"
+ "github.com/apisix/manager-api/conf"
+ "github.com/sirupsen/logrus"
+ "os"
+ "runtime"
+ "strings"
+)
+
+var logEntry *logrus.Entry
+
+func GetLogger() *logrus.Entry {
+ if logEntry == nil {
+ var log = logrus.New()
+ setNull(log)
+ log.SetLevel(logrus.DebugLevel)
+ if conf.ENV != conf.LOCAL {
+ log.SetLevel(logrus.ErrorLevel)
+ }
+ log.SetFormatter(&logrus.JSONFormatter{})
+ logEntry = log.WithFields(logrus.Fields{
+ "app": "manager-api",
+ })
+ if hook, err := createHook(); err == nil {
+ log.AddHook(hook)
+ }
+ }
+ return logEntry
+}
+
+func setNull(log *logrus.Logger) {
+ src, err := os.OpenFile(os.DevNull, os.O_APPEND|os.O_WRONLY, os.ModeAppend)
+ if err != nil {
+ fmt.Println("err", err)
+ }
+ writer := bufio.NewWriter(src)
+ log.SetOutput(writer)
+}
+
+type Hook struct {
+ Formatter func(file, function string, line int) string
+}
+
+func createHook() (*Hook, error) {
+ return &Hook{
+ func(file, function string, line int) string {
+ return fmt.Sprintf("%s:%d", file, line)
+ },
+ }, nil
+}
+
+func (hook *Hook) Fire(entry *logrus.Entry) error {
+ str := hook.Formatter(findCaller(5))
+ en := entry.WithField("line", str)
+ en.Level = entry.Level
+ en.Message = entry.Message
+ en.Time = entry.Time
+ line, err := en.String()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Unable to read entry, %v", err)
+ return err
+ }
+ switch en.Level {
+ case logrus.PanicLevel:
+ fmt.Print(line)
+ return nil
+ case logrus.FatalLevel:
+ fmt.Print(line)
+ return nil
+ case logrus.ErrorLevel:
+ fmt.Print(line)
+ return nil
+ case logrus.WarnLevel:
+ fmt.Print(line)
+ return nil
+ case logrus.InfoLevel:
+ fmt.Print(line)
+ return nil
+ case logrus.DebugLevel:
+ fmt.Print(line)
+ return nil
+ default:
+ return nil
+ }
+}
+
+func (hook *Hook) Levels() []logrus.Level {
+ return logrus.AllLevels
+}
+
+func findCaller(skip int) (string, string, int) {
+ var (
+ pc uintptr
+ file string
+ function string
+ line int
+ )
+ for i := 0; i < 10; i++ {
+ pc, file, line = getCaller(skip + i)
+ if !strings.HasPrefix(file, "logrus") {
+ break
+ }
+ }
+ if pc != 0 {
+ frames := runtime.CallersFrames([]uintptr{pc})
+ frame, _ := frames.Next()
+ function = frame.Function
+ }
+ return file, function, line
+}
+
+func getCaller(skip int) (uintptr, string, int) {
+ pc, file, line, ok := runtime.Caller(skip)
+ if !ok {
+ return 0, "", 0
+ }
+ n := 0
+ for i := len(file) - 1; i > 0; i-- {
+ if file[i] == '/' {
+ n += 1
+ if n >= 2 {
+ file = file[i+1:]
+ break
+ }
+ }
+ }
+ return pc, file, line
+}
diff --git a/api/main.go b/api/main.go
new file mode 100644
index 0000000..3d756dd
--- /dev/null
+++ b/api/main.go
@@ -0,0 +1,65 @@
+/*
+ * 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.
+ */
+package main
+
+import (
+ "fmt"
+ "github.com/apisix/manager-api/conf"
+ "github.com/apisix/manager-api/filter"
+ "github.com/apisix/manager-api/log"
+ "github.com/apisix/manager-api/route"
+ "github.com/gin-contrib/pprof"
+ "github.com/gin-gonic/gin"
+ "net/http"
+ "time"
+)
+
+var logger = log.GetLogger()
+
+func setUpRouter() *gin.Engine {
+ if conf.ENV != conf.LOCAL && conf.ENV != conf.BETA {
+ gin.SetMode(gin.DebugMode)
+ } else {
+ gin.SetMode(gin.ReleaseMode)
+ }
+ r := gin.New()
+
+ r.Use(filter.CORS(), filter.RequestId(), filter.RequestLogHandler(), filter.RecoverHandler())
+ route.AppendHealthCheck(r)
+ route.AppendRoute(r)
+ route.AppendSsl(r)
+ route.AppendPlugin(r)
+
+ pprof.Register(r)
+
+ return r
+}
+
+func main() {
+ // init
+ conf.InitializeMysql()
+ // routes
+ r := setUpRouter()
+ addr := fmt.Sprintf(":%d", conf.ServerPort)
+ s := &http.Server{
+ Addr: addr,
+ Handler: r,
+ ReadTimeout: time.Duration(1000) * time.Millisecond,
+ WriteTimeout: time.Duration(5000) * time.Millisecond,
+ }
+ s.ListenAndServe()
+}
diff --git a/api/route/healthz.go b/api/route/healthz.go
new file mode 100644
index 0000000..97dcd04
--- /dev/null
+++ b/api/route/healthz.go
@@ -0,0 +1,37 @@
+/*
+ * 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.
+ */
+package route
+
+import (
+ "github.com/apisix/manager-api/log"
+ "github.com/gin-gonic/gin"
+ "net/http"
+)
+
+var logger = log.GetLogger()
+
+func healthzHandler() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.Copy()
+ c.String(http.StatusOK, "pong")
+ }
+}
+
+func AppendHealthCheck(r *gin.Engine) *gin.Engine {
+ r.GET("/ping", healthzHandler())
+ return r
+}
diff --git a/api/route/plugin.go b/api/route/plugin.go
new file mode 100644
index 0000000..0032b13
--- /dev/null
+++ b/api/route/plugin.go
@@ -0,0 +1,58 @@
+/*
+ * 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.
+ */
+package route
+
+import (
+ "encoding/json"
+ "github.com/apisix/manager-api/errno"
+ "github.com/apisix/manager-api/service"
+ "github.com/gin-gonic/gin"
+ "net/http"
+)
+
+func AppendPlugin(r *gin.Engine) *gin.Engine {
+ r.GET("/apisix/admin/plugins", listPlugin)
+ r.GET("/apisix/admin/schema/plugins/:name", findSchema)
+ return r
+}
+
+func findSchema(c *gin.Context) {
+ name := c.Param("name")
+ request := &service.ApisixPluginRequest{Name: name}
+ if result, err := request.Schema(); err != nil {
+ e := errno.FromMessage(errno.ApisixPluginSchemaError, err.Error())
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusBadRequest, e.Response())
+ return
+ } else {
+ resp, _ := json.Marshal(result)
+ c.Data(http.StatusOK, service.ContentType, resp)
+ }
+}
+
+func listPlugin(c *gin.Context) {
+ request := &service.ApisixPluginRequest{}
+ if result, err := request.List(); err != nil {
+ e := errno.FromMessage(errno.ApisixPluginListError, err.Error())
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusBadRequest, e.Response())
+ return
+ } else {
+ resp, _ := json.Marshal(result)
+ c.Data(http.StatusOK, service.ContentType, resp)
+ }
+}
diff --git a/api/route/route.go b/api/route/route.go
new file mode 100644
index 0000000..efc95d2
--- /dev/null
+++ b/api/route/route.go
@@ -0,0 +1,242 @@
+/*
+ * 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.
+ */
+package route
+
+import (
+ "encoding/json"
+ "github.com/apisix/manager-api/conf"
+ "github.com/apisix/manager-api/errno"
+ "github.com/apisix/manager-api/service"
+ "github.com/gin-gonic/gin"
+ "github.com/satori/go.uuid"
+ "net/http"
+ "strconv"
+)
+
+func AppendRoute(r *gin.Engine) *gin.Engine {
+ r.POST("/apisix/admin/routes", createRoute)
+ r.GET("/apisix/admin/routes/:rid", findRoute)
+ r.GET("/apisix/admin/routes", listRoute)
+ r.PUT("/apisix/admin/routes/:rid", updateRoute)
+ r.DELETE("/apisix/admin/routes/:rid", deleteRoute)
+ return r
+}
+
+func listRoute(c *gin.Context) {
+ db := conf.DB()
+ size, _ := strconv.Atoi(c.Query("size"))
+ page, _ := strconv.Atoi(c.Query("page"))
+ if size == 0 {
+ size = 10
+ }
+ isSearch := true
+ if name, exist := c.GetQuery("name"); exist {
+ db = db.Where("name like ? ", "%"+name+"%")
+ isSearch = false
+ }
+ if description, exist := c.GetQuery("description"); exist {
+ db = db.Where("description like ? ", "%"+description+"%")
+ isSearch = false
+ }
+ if host, exist := c.GetQuery("host"); exist {
+ db = db.Where("hosts like ? ", "%"+host+"%")
+ isSearch = false
+ }
+ if uri, exist := c.GetQuery("uri"); exist {
+ db = db.Where("uris like ? ", "%"+uri+"%")
+ isSearch = false
+ }
+ if ip, exist := c.GetQuery("ip"); exist {
+ db = db.Where("upstream_nodes like ? ", "%"+ip+"%")
+ isSearch = false
+ }
+ // search
+ if isSearch {
+ if search, exist := c.GetQuery("search"); exist {
+ db = db.Where("name like ? ", "%"+search+"%").
+ Or("description like ? ", "%"+search+"%").
+ Or("hosts like ? ", "%"+search+"%").
+ Or("uris like ? ", "%"+search+"%").
+ Or("upstream_nodes like ? ", "%"+search+"%")
+ }
+ }
+ // todo params check
+ // mysql
+ routeList := []service.Route{}
+ var count int
+ if err := db.Order("priority, update_time desc").Table("routes").Offset((page - 1) * size).Limit(size).Find(&routeList).Count(&count).Error; err != nil {
+ e := errno.FromMessage(errno.RouteRequestError, err.Error())
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusBadRequest, e.Response())
+ return
+ } else {
+ responseList := make([]service.RouteResponse, 0)
+ for _, r := range routeList {
+ response := &service.RouteResponse{}
+ response.Parse(&r)
+ responseList = append(responseList, *response)
+ }
+ result := &service.ListResponse{Count: count, Data: responseList}
+ resp, _ := json.Marshal(result)
+ c.Data(http.StatusOK, service.ContentType, resp)
+ }
+}
+
+func deleteRoute(c *gin.Context) {
+ rid := c.Param("rid")
+ // todo params check
+ // delete from apisix
+ request := &service.ApisixRouteRequest{}
+ if _, err := request.Delete(rid); err != nil {
+ e := errno.FromMessage(errno.ApisixRouteDeleteError, err.Error())
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusInternalServerError, e.Response())
+ return
+ } else {
+ // delete from mysql
+ rd := &service.Route{}
+ rd.ID = uuid.FromStringOrNil(rid)
+ if err := conf.DB().Delete(rd).Error; err != nil {
+ e := errno.FromMessage(errno.DBRouteDeleteError, err.Error())
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusInternalServerError, e.Response())
+ return
+ }
+ }
+ c.Data(http.StatusOK, service.ContentType, errno.Success())
+}
+func updateRoute(c *gin.Context) {
+ rid := c.Param("rid")
+ // todo params check
+ param, exist := c.Get("requestBody")
+ if !exist || len(param.([]byte)) < 1 {
+ e := errno.FromMessage(errno.RouteRequestError, "route create with no post data")
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusBadRequest, e.Response())
+ return
+ }
+ routeRequest := &service.RouteRequest{}
+ if err := routeRequest.Parse(param); err != nil {
+ e := errno.FromMessage(errno.RouteRequestError, err.Error())
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusBadRequest, e.Response())
+ return
+ }
+ logger.Info(routeRequest.Plugins)
+
+ arr := service.ToApisixRequest(routeRequest)
+ logger.Info(arr)
+ if resp, err := arr.Update(rid); err != nil {
+ e := errno.FromMessage(errno.ApisixRouteUpdateError, err.Error())
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusInternalServerError, e.Response())
+ return
+ } else {
+ // update mysql
+ if rd, err := service.ToRoute(routeRequest, arr, uuid.FromStringOrNil(rid), resp); err != nil {
+ c.AbortWithStatusJSON(http.StatusInternalServerError, err.Response())
+ return
+ } else {
+ if err := conf.DB().Model(&service.Route{}).Update(rd).Error; err != nil {
+ e := errno.FromMessage(errno.DBRouteUpdateError, err.Error())
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusInternalServerError, e.Response())
+ return
+ }
+ logger.Info(rd)
+ }
+ }
+ c.Data(http.StatusOK, service.ContentType, errno.Success())
+}
+
+func findRoute(c *gin.Context) {
+ rid := c.Param("rid")
+ // todo params check
+ // find from apisix
+ request := &service.ApisixRouteRequest{}
+ if response, err := request.FindById(rid); err != nil {
+ e := errno.FromMessage(errno.RouteRequestError, err.Error()+" route ID: "+rid)
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusBadRequest, e.Response())
+ return
+ } else {
+ // transfer response to dashboard struct
+ if result, err := response.Parse(); err != nil {
+ e := errno.FromMessage(errno.RouteRequestError, err.Error()+" route ID: "+rid)
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusBadRequest, e.Response())
+ return
+ } else {
+ // need to find name from mysql temporary
+ route := &service.Route{}
+ if err := conf.DB().Table("routes").Where("id=?", rid).First(&route).Error; err != nil {
+ e := errno.FromMessage(errno.RouteRequestError, err.Error()+" route ID: "+rid)
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusBadRequest, e.Response())
+ return
+ }
+ result.Name = route.Name
+ resp, _ := json.Marshal(result)
+ c.Data(http.StatusOK, service.ContentType, resp)
+ }
+ }
+}
+
+func createRoute(c *gin.Context) {
+ u4 := uuid.NewV4()
+ rid := u4.String()
+ // todo params check
+ param, exist := c.Get("requestBody")
+ if !exist || len(param.([]byte)) < 1 {
+ e := errno.FromMessage(errno.RouteRequestError, "route create with no post data")
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusBadRequest, e.Response())
+ return
+ }
+ routeRequest := &service.RouteRequest{}
+ if err := routeRequest.Parse(param); err != nil {
+ e := errno.FromMessage(errno.RouteRequestError, err.Error())
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusBadRequest, e.Response())
+ return
+ }
+ logger.Info(routeRequest.Plugins)
+
+ arr := service.ToApisixRequest(routeRequest)
+ logger.Info(arr)
+ if resp, err := arr.Create(rid); err != nil {
+ e := errno.FromMessage(errno.ApisixRouteCreateError, err.Error())
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusInternalServerError, e.Response())
+ return
+ } else {
+ // update mysql
+ if rd, err := service.ToRoute(routeRequest, arr, u4, resp); err != nil {
+ c.AbortWithStatusJSON(http.StatusInternalServerError, err.Response())
+ return
+ } else {
+ logger.Info(rd)
+ if err := conf.DB().Create(rd).Error; err != nil {
+ e := errno.FromMessage(errno.DBRouteCreateError, err.Error())
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusInternalServerError, e.Response())
+ return
+ }
+ }
+ }
+ c.Data(http.StatusOK, service.ContentType, errno.Success())
+}
diff --git a/api/route/ssl.go b/api/route/ssl.go
new file mode 100644
index 0000000..2a9d6f7
--- /dev/null
+++ b/api/route/ssl.go
@@ -0,0 +1,153 @@
+/*
+ * 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.
+ */
+package route
+
+import (
+ "net/http"
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+ "github.com/satori/go.uuid"
+
+ "github.com/apisix/manager-api/errno"
+ "github.com/apisix/manager-api/service"
+)
+
+const contentType = "application/json"
+
+func AppendSsl(r *gin.Engine) *gin.Engine {
+ r.POST("/apisix/admin/check_ssl_cert", sslCheck)
+ r.GET("/apisix/admin/ssls", sslList)
+ r.POST("/apisix/admin/ssls", sslCreate)
+ r.GET("/apisix/admin/ssls/:id", sslItem)
+ r.PUT("/apisix/admin/ssls/:id", sslUpdate)
+ r.DELETE("/apisix/admin/ssls/:id", sslDelete)
+ return r
+}
+
+func sslList(c *gin.Context) {
+ size, _ := strconv.Atoi(c.DefaultQuery("size", "10"))
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ // todo params check
+ resp, err := service.SslList(page, size)
+
+ if err != nil {
+ e := errno.FromMessage(errno.RouteRequestError, err.Error())
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusBadRequest, e.Response())
+ return
+ }
+
+ c.Data(http.StatusOK, service.ContentType, resp)
+}
+
+func sslItem(c *gin.Context) {
+ id := c.Param("id")
+
+ // todo params check
+ resp, err := service.SslItem(id)
+
+ if err != nil {
+ e := errno.FromMessage(errno.RouteRequestError, err.Error())
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusBadRequest, e.Response())
+ return
+ }
+
+ c.Data(http.StatusOK, service.ContentType, resp)
+}
+
+func sslCheck(c *gin.Context) {
+ // todo params check
+ param, exist := c.Get("requestBody")
+
+ if !exist || len(param.([]byte)) < 1 {
+ e := errno.FromMessage(errno.RouteRequestError, "route create with no post data")
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusBadRequest, e.Response())
+ return
+ }
+
+ resp, err := service.SslCheck(param)
+ if err != nil {
+ e := errno.FromMessage(errno.RouteRequestError, err.Error())
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusBadRequest, e.Response())
+ return
+ }
+
+ c.Data(http.StatusOK, contentType, resp)
+}
+
+func sslCreate(c *gin.Context) {
+ // todo params check
+ param, exist := c.Get("requestBody")
+
+ u4 := uuid.NewV4()
+
+ if !exist || len(param.([]byte)) < 1 {
+ e := errno.FromMessage(errno.RouteRequestError, "route create with no post data")
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusBadRequest, e.Response())
+ return
+ }
+
+ if err := service.SslCreate(param, u4.String()); err != nil {
+ e := errno.FromMessage(errno.ApisixRouteCreateError, err.Error())
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusInternalServerError, e.Response())
+ return
+ }
+
+ c.Data(http.StatusOK, contentType, errno.Success())
+}
+
+func sslUpdate(c *gin.Context) {
+ // todo params check
+ param, exist := c.Get("requestBody")
+
+ id := c.Param("id")
+
+ if !exist || len(param.([]byte)) < 1 {
+ e := errno.FromMessage(errno.RouteRequestError, "route create with no post data")
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusBadRequest, e.Response())
+ return
+ }
+
+ if err := service.SslUpdate(param, id); err != nil {
+ e := errno.FromMessage(errno.ApisixRouteCreateError, err.Error())
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusInternalServerError, e.Response())
+ return
+ }
+
+ c.Data(http.StatusOK, contentType, errno.Success())
+}
+
+func sslDelete(c *gin.Context) {
+ id := c.Param("id")
+ // todo params check
+ if err := service.SslDelete(id); err != nil {
+ e := errno.FromMessage(errno.RouteRequestError, err.Error())
+ logger.Error(e.Msg)
+ c.AbortWithStatusJSON(http.StatusBadRequest, e.Response())
+ return
+ }
+
+ c.Data(http.StatusOK, service.ContentType, errno.Success())
+}
diff --git a/api/script/db/schema.sql b/api/script/db/schema.sql
new file mode 100644
index 0000000..69f088c
--- /dev/null
+++ b/api/script/db/schema.sql
@@ -0,0 +1,31 @@
+-- this is a db script for init
+CREATE DATABASE `manager`;
+use `manager`;
+CREATE TABLE `routes` (
+ `id` varchar(64) NOT NULL unique,
+ `name` varchar(200) NOT NULL unique, -- not support yet
+ `description` varchar(200) DEFAULT NULL,
+ `hosts` text,
+ `uris` text,
+ `upstream_nodes` text,
+ `upstream_id` varchar(32) , -- fk
+ `priority` int NOT NULL DEFAULT 0,
+ `state` int NOT NULL DEFAULT 1, -- 1-normal 0-disable
+ `content` text,
+ `content_admin_api` text,
+ `create_time` bigint(20),
+ `update_time` bigint(20),
+
+ PRIMARY KEY (`id`)
+) DEFAULT CHARSET=utf8;
+CREATE TABLE `ssls` (
+ `id` char(36) NOT NULL DEFAULT '',
+ `public_key` text NOT NULL,
+ `snis` text NOT NULL,
+ `validity_start` bigint(20) unsigned NOT NULL,
+ `validity_end` bigint(20) unsigned NOT NULL,
+ `status` tinyint(1) unsigned NOT NULL DEFAULT '1',
+ `create_time` bigint(20) unsigned NOT NULL,
+ `update_time` bigint(20) unsigned NOT NULL,
+ PRIMARY KEY (`id`)
+) DEFAULT CHARSET=utf8;
\ No newline at end of file
diff --git a/api/service/base.go b/api/service/base.go
new file mode 100644
index 0000000..6ee57b5
--- /dev/null
+++ b/api/service/base.go
@@ -0,0 +1,48 @@
+/*
+ * 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.
+ */
+package service
+
+import (
+ "github.com/jinzhu/gorm"
+ "github.com/satori/go.uuid"
+ "time"
+)
+
+// Base contains common columns for all tables.
+type Base struct {
+ ID uuid.UUID `json:"id",sql:"type:uuid;primary_key;"`
+ CreateTime int64 `json:"create_time"`
+ UpdateTime int64 `json:"update_time"`
+}
+
+// BeforeCreate will set a UUID rather than numeric ID.
+func (base *Base) BeforeCreate(scope *gorm.Scope) error {
+ timestamp := time.Now().Unix()
+ err := scope.SetColumn("UpdateTime", timestamp)
+ err = scope.SetColumn("CreateTime", timestamp)
+ if len(base.ID) == 0 {
+ uuid := uuid.NewV4()
+ err = scope.SetColumn("ID", uuid)
+ return err
+ }
+ return err
+}
+
+func (base *Base) BeforeSave(scope *gorm.Scope) error {
+ err := scope.SetColumn("UpdateTime", time.Now().Unix())
+ return err
+}
diff --git a/api/service/plugin.go b/api/service/plugin.go
new file mode 100644
index 0000000..92cd6c3
--- /dev/null
+++ b/api/service/plugin.go
@@ -0,0 +1,61 @@
+/*
+ * 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.
+ */
+package service
+
+import (
+ "encoding/json"
+ "fmt"
+ "github.com/apisix/manager-api/conf"
+ "github.com/apisix/manager-api/utils"
+)
+
+type ApisixPluginRequest struct {
+ Name string `json:"name"`
+}
+
+func (apr *ApisixPluginRequest) Schema() (map[string]interface{}, error) {
+ url := fmt.Sprintf("%s/schema/plugins/%s", conf.BaseUrl, apr.Name)
+ if resp, err := utils.Get(url); err != nil {
+ logger.Error(err.Error())
+ return nil, err
+ } else {
+ arresp := make(map[string]interface{})
+ if err := json.Unmarshal(resp, &arresp); err != nil {
+ logger.Error(err.Error())
+ return nil, err
+ } else {
+ return arresp, nil
+ }
+ }
+}
+
+func (apr *ApisixPluginRequest) List() ([]string, error) {
+
+ url := fmt.Sprintf("%s/plugins/list", conf.BaseUrl)
+ if resp, err := utils.Get(url); err != nil {
+ logger.Error(err.Error())
+ return nil, err
+ } else {
+ var arresp []string
+ if err := json.Unmarshal(resp, &arresp); err != nil {
+ logger.Error(err.Error())
+ return nil, err
+ } else {
+ return arresp, nil
+ }
+ }
+}
diff --git a/api/service/route.go b/api/service/route.go
new file mode 100644
index 0000000..dae7278
--- /dev/null
+++ b/api/service/route.go
@@ -0,0 +1,560 @@
+/*
+ * 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.
+ */
+package service
+
+import (
+ "encoding/json"
+ "fmt"
+ "github.com/apisix/manager-api/conf"
+ "github.com/apisix/manager-api/errno"
+ "github.com/apisix/manager-api/log"
+ "github.com/apisix/manager-api/utils"
+ "github.com/satori/go.uuid"
+ "time"
+)
+
+const (
+ ContentType = "application/json"
+ HTTP = "http"
+ HTTPS = "https"
+ SCHEME = "scheme"
+ WEBSOCKET = "websocket"
+ REDIRECT = "redirect"
+ PROXY_REWRIETE = "proxy-rewrite"
+ UPATHTYPE_STATIC = "static"
+ UPATHTYPE_REGX = "regx"
+ UPATHTYPE_KEEP = "keep"
+)
+
+var logger = log.GetLogger()
+
+func (r *RouteRequest) Parse(body interface{}) error {
+ if err := json.Unmarshal(body.([]byte), r); err != nil {
+ r = nil
+ return err
+ } else {
+ if r.Uris == nil || len(r.Uris) < 1 {
+ r.Uris = []string{"/*"}
+ }
+ }
+ return nil
+}
+
+func (arr *ApisixRouteRequest) Parse(r *RouteRequest) {
+ arr.Desc = r.Desc
+ arr.Priority = r.Priority
+ arr.Methods = r.Methods
+ arr.Uris = r.Uris
+ arr.Hosts = r.Hosts
+ arr.Vars = r.Vars
+ arr.Upstream = r.Upstream
+ arr.Plugins = r.Plugins
+}
+
+func (rd *Route) Parse(r *RouteRequest, arr *ApisixRouteRequest) error {
+ //rd.Name = arr.Name
+ rd.Description = arr.Desc
+ // todo transfer
+ rd.Hosts = ""
+ rd.Uris = ""
+ rd.UpstreamNodes = ""
+ rd.UpstreamId = ""
+ if content, err := json.Marshal(r); err != nil {
+ return err
+ } else {
+ rd.Content = string(content)
+ }
+ timestamp := time.Now().Unix()
+ rd.CreateTime = timestamp
+ return nil
+}
+
+func (arr *ApisixRouteRequest) FindById(rid string) (*ApisixRouteResponse, error) {
+ url := fmt.Sprintf("%s/routes/%s", conf.BaseUrl, rid)
+ if resp, err := utils.Get(url); err != nil {
+ logger.Error(err.Error())
+ return nil, err
+ } else {
+ var arresp ApisixRouteResponse
+ if err := json.Unmarshal(resp, &arresp); err != nil {
+ logger.Error(err.Error())
+ return nil, err
+ } else {
+ return &arresp, nil
+ }
+ }
+}
+
+func (arr *ApisixRouteRequest) Update(rid string) (*ApisixRouteResponse, error) {
+ url := fmt.Sprintf("%s/routes/%s", conf.BaseUrl, rid)
+ if b, err := json.Marshal(arr); err != nil {
+ return nil, err
+ } else {
+ if resp, err := utils.Patch(url, b); err != nil {
+ logger.Error(err.Error())
+ return nil, err
+ } else {
+ var arresp ApisixRouteResponse
+ if err := json.Unmarshal(resp, &arresp); err != nil {
+ logger.Error(err.Error())
+ return nil, err
+ } else {
+ return &arresp, nil
+ }
+ }
+ }
+}
+
+func (arr *ApisixRouteRequest) Create(rid string) (*ApisixRouteResponse, error) {
+ url := fmt.Sprintf("%s/routes/%s", conf.BaseUrl, rid)
+ if b, err := json.Marshal(arr); err != nil {
+ return nil, err
+ } else {
+ fmt.Println(string(b))
+ if resp, err := utils.Put(url, b); err != nil {
+ logger.Error(err.Error())
+ return nil, err
+ } else {
+ var arresp ApisixRouteResponse
+ if err := json.Unmarshal(resp, &arresp); err != nil {
+ logger.Error(err.Error())
+ return nil, err
+ } else {
+ return &arresp, nil
+ }
+ }
+ }
+}
+
+func (arr *ApisixRouteRequest) Delete(rid string) (*ApisixRouteResponse, error) {
+ url := fmt.Sprintf("%s/routes/%s", conf.BaseUrl, rid)
+ if resp, err := utils.Delete(url); err != nil {
+ logger.Error(err.Error())
+ return nil, err
+ } else {
+ var arresp ApisixRouteResponse
+ if err := json.Unmarshal(resp, &arresp); err != nil {
+ logger.Error(err.Error())
+ return nil, err
+ } else {
+ return &arresp, nil
+ }
+ }
+}
+
+type RouteRequest struct {
+ ID string `json:"id,omitempty"`
+ Name string `json:"name"`
+ Desc string `json:"desc,omitempty"`
+ Priority int64 `json:"priority,omitempty"`
+ Methods []string `json:"methods,omitempty"`
+ Uris []string `json:"uris"`
+ Hosts []string `json:"hosts,omitempty"`
+ Protocols []string `json:"protocols,omitempty"`
+ Redirect *Redirect `json:"redirect,omitempty"`
+ Vars [][]string `json:"vars,omitempty"`
+ Upstream *Upstream `json:"upstream,omitempty"`
+ UpstreamProtocol string `json:"upstream_protocol,omitempty"`
+ UpstreamPath *UpstreamPath `json:"upstream_path,omitempty"`
+ UpstreamHeader map[string]string `json:"upstream_header,omitempty"`
+ Plugins map[string]interface{} `json:"plugins"`
+}
+
+func (r *ApisixRouteResponse) Parse() (*RouteRequest, error) {
+ o := r.Node.Value
+
+ //Protocols from vars and upstream
+ protocols := make([]string, 0)
+ if o.Upstream != nil && o.Upstream.EnableWebsocket {
+ protocols = append(protocols, WEBSOCKET)
+ }
+ flag := true
+ for _, t := range o.Vars {
+ if t[0] == SCHEME {
+ flag = false
+ protocols = append(protocols, t[2])
+ }
+ }
+ if flag {
+ protocols = append(protocols, HTTP)
+ protocols = append(protocols, HTTPS)
+ }
+ //Redirect from plugins
+ redirect := &Redirect{}
+ upstreamProtocol := UPATHTYPE_KEEP
+ upstreamHeader := make(map[string]string)
+ upstreamPath := &UpstreamPath{}
+ for k, v := range o.Plugins {
+ if k == REDIRECT {
+ if bytes, err := json.Marshal(v); err != nil {
+ return nil, err
+ } else {
+ if err := json.Unmarshal(bytes, redirect); err != nil {
+ return nil, err
+ }
+ }
+
+ }
+ if k == PROXY_REWRIETE {
+ pr := &ProxyRewrite{}
+ if bytes, err := json.Marshal(v); err != nil {
+ return nil, err
+ } else {
+ if err := json.Unmarshal(bytes, pr); err != nil {
+ return nil, err
+ } else {
+ if pr.Scheme != "" {
+ upstreamProtocol = pr.Scheme
+ }
+ upstreamHeader = pr.Headers
+ if pr.RegexUri == nil || len(pr.RegexUri) < 2 {
+ upstreamPath.UPathType = UPATHTYPE_STATIC
+ upstreamPath.To = pr.Uri
+ } else {
+ upstreamPath.UPathType = UPATHTYPE_REGX
+ upstreamPath.From = pr.RegexUri[0]
+ upstreamPath.To = pr.RegexUri[1]
+ }
+ }
+ }
+ }
+ }
+ //Vars
+ requestVars := make([][]string, 0)
+ for _, t := range o.Vars {
+ if t[0] != SCHEME {
+ requestVars = append(requestVars, t)
+ }
+ }
+ //Plugins
+ requestPlugins := utils.CopyMap(o.Plugins)
+ delete(requestPlugins, REDIRECT)
+
+ // check if upstream is not exist
+ if o.Upstream == nil {
+ upstreamProtocol = ""
+ upstreamHeader = nil
+ upstreamPath = nil
+ }
+ if upstreamPath.UPathType == "" {
+ upstreamPath = nil
+ }
+ result := &RouteRequest{
+ ID: o.Id,
+ Desc: o.Desc,
+ Priority: o.Priority,
+ Methods: o.Methods,
+ Uris: o.Uris,
+ Hosts: o.Hosts,
+ Redirect: redirect,
+ Upstream: o.Upstream,
+ UpstreamProtocol: upstreamProtocol,
+ UpstreamPath: upstreamPath,
+ UpstreamHeader: upstreamHeader,
+ Protocols: protocols,
+ Vars: requestVars,
+ Plugins: requestPlugins,
+ }
+ return result, nil
+}
+
+type Redirect struct {
+ HttpToHttps bool `json:"http_to_https,omitempty"`
+ Code int64 `json:"code,omitempty"`
+ Uri string `json:"uri,omitempty"`
+}
+
+type ProxyRewrite struct {
+ Uri string `json:"uri"`
+ RegexUri []string `json:"regex_uri"`
+ Scheme string `json:"scheme"`
+ Host string `json:"host"`
+ Headers map[string]string `json:"headers"`
+}
+
+func (r ProxyRewrite) MarshalJSON() ([]byte, error) {
+ m := make(map[string]interface{})
+ if r.RegexUri != nil {
+ m["regex_uri"] = r.RegexUri
+ }
+ if r.Uri != "" {
+ m["uri"] = r.Uri
+ }
+ if r.Scheme != UPATHTYPE_KEEP && r.Scheme != "" {
+ m["scheme"] = r.Scheme
+ }
+ if r.Host != "" {
+ m["host"] = r.Host
+ }
+ if r.Headers != nil && len(r.Headers) > 0 {
+ m["headers"] = r.Headers
+ }
+ if result, err := json.Marshal(m); err != nil {
+ return nil, err
+ } else {
+ return result, nil
+ }
+}
+
+func (r Redirect) MarshalJSON() ([]byte, error) {
+ m := make(map[string]interface{})
+ if r.HttpToHttps {
+ m["http_to_https"] = true
+ } else {
+ m["code"] = r.Code
+ m["uri"] = r.Uri
+ }
+ if result, err := json.Marshal(m); err != nil {
+ return nil, err
+ } else {
+ return result, nil
+ }
+}
+
+type Upstream struct {
+ UType string `json:"type"`
+ Nodes map[string]int64 `json:"nodes"`
+ Timeout UpstreamTimeout `json:"timeout"`
+ EnableWebsocket bool `json:"enable_websocket"`
+}
+
+type UpstreamTimeout struct {
+ Connect int64 `json:"connect"`
+ Send int64 `json:"send"`
+ Read int64 `json:"read"`
+}
+
+type UpstreamPath struct {
+ UPathType string `json:"type"`
+ From string `json:"from"`
+ To string `json:"to"`
+}
+
+type ApisixRouteRequest struct {
+ Desc string `json:"desc,omitempty"`
+ Priority int64 `json:"priority"`
+ Methods []string `json:"methods,omitempty"`
+ Uris []string `json:"uris,omitempty"`
+ Hosts []string `json:"hosts,omitempty"`
+ Vars [][]string `json:"vars,omitempty"`
+ Upstream *Upstream `json:"upstream,omitempty"`
+ Plugins map[string]interface{} `json:"plugins,omitempty"`
+ //Name string `json:"name"`
+}
+
+// ApisixRouteResponse is response from apisix admin api
+type ApisixRouteResponse struct {
+ Action string `json:"action"`
+ Node *Node `json:"node"`
+}
+
+type Node struct {
+ Value Value `json:"value"`
+ ModifiedIndex uint64 `json:"modifiedIndex"`
+}
+
+type Value struct {
+ Id string `json:"id"`
+ Name string `json:"name"`
+ Desc string `json:"desc,omitempty"`
+ Priority int64 `json:"priority"`
+ Methods []string `json:"methods"`
+ Uris []string `json:"uris"`
+ Hosts []string `json:"hosts"`
+ Vars [][]string `json:"vars"`
+ Upstream *Upstream `json:"upstream,omitempty"`
+ UpstreamId string `json:"upstream_id,omitempty"`
+ Plugins map[string]interface{} `json:"plugins"`
+}
+
+type Route struct {
+ Base
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Hosts string `json:"hosts"`
+ Uris string `json:"uris"`
+ UpstreamNodes string `json:"upstream_nodes"`
+ UpstreamId string `json:"upstream_id"`
+ Priority int64 `json:"priority"`
+ Content string `json:"content"`
+ ContentAdminApi string `json:"content_admin_api"`
+}
+
+type RouteResponse struct {
+ Base
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Hosts []string `json:"hosts,omitempty"`
+ Uris []string `json:"uris,omitempty"`
+ Upstream *Upstream `json:"upstream,omitempty"`
+ UpstreamId string `json:"upstream_id,omitempty"`
+ Priority int64 `json:"priority"`
+}
+
+type ListResponse struct {
+ Count int `json:"count"`
+ Data interface{} `json:"data"`
+}
+
+func (rr *RouteResponse) Parse(r *Route) {
+ rr.Base = r.Base
+ rr.Name = r.Name
+ rr.Description = r.Description
+ rr.UpstreamId = r.UpstreamId
+ rr.Priority = r.Priority
+ // hosts
+ if len(r.Hosts) > 0 {
+ var hosts []string
+ if err := json.Unmarshal([]byte(r.Hosts), &hosts); err == nil {
+ rr.Hosts = hosts
+ } else {
+ logger.Error(err.Error())
+ }
+ }
+
+ // uris
+ if len(r.Uris) > 0 {
+ var uris []string
+ if err := json.Unmarshal([]byte(r.Uris), &uris); err == nil {
+ rr.Uris = uris
+ }
+ }
+
+ // uris
+ var resp ApisixRouteResponse
+ if err := json.Unmarshal([]byte(r.ContentAdminApi), &resp); err == nil {
+ rr.Upstream = resp.Node.Value.Upstream
+ }
+}
+
+// RouteRequest -> ApisixRouteRequest
+func ToApisixRequest(routeRequest *RouteRequest) *ApisixRouteRequest {
+ // redirect -> plugins
+ plugins := utils.CopyMap(routeRequest.Plugins)
+ redirect := routeRequest.Redirect
+ if redirect != nil {
+ plugins["redirect"] = redirect
+ }
+
+ logger.Info(routeRequest.Plugins)
+
+ // scheme https and not http -> vars ['scheme', '==', 'https']
+ pMap := utils.Set2Map(routeRequest.Protocols)
+
+ arr := &ApisixRouteRequest{}
+ arr.Parse(routeRequest)
+
+ // protocols[websokect] -> upstream
+ if pMap[WEBSOCKET] == 1 && arr.Upstream != nil {
+ arr.Upstream.EnableWebsocket = true
+ }
+ vars := utils.CopyStrings(routeRequest.Vars)
+ if pMap[HTTP] != 1 || pMap[HTTPS] != 1 {
+ if pMap[HTTP] == 1 {
+ vars = append(vars, []string{SCHEME, "==", HTTP})
+ }
+ if pMap[HTTPS] == 1 {
+ vars = append(vars, []string{SCHEME, "==", HTTPS})
+ }
+ }
+ if len(vars) > 0 {
+ arr.Vars = vars
+ } else {
+ arr.Vars = nil
+ }
+
+ // upstream protocol
+ if arr.Upstream != nil {
+ pr := &ProxyRewrite{}
+ pr.Scheme = routeRequest.UpstreamProtocol
+ // upstream path
+ proxyPath := routeRequest.UpstreamPath
+ if proxyPath != nil {
+ if proxyPath.UPathType == UPATHTYPE_STATIC {
+ pr.Uri = proxyPath.To
+ pr.RegexUri = nil
+ } else {
+ pr.RegexUri = []string{proxyPath.From, proxyPath.To}
+ }
+ }
+ // upstream headers
+ pr.Headers = routeRequest.UpstreamHeader
+ if proxyPath != nil {
+ plugins[PROXY_REWRIETE] = pr
+ }
+ }
+
+ if plugins != nil && len(plugins) > 0 {
+ arr.Plugins = plugins
+ } else {
+ arr.Plugins = nil
+ }
+ return arr
+}
+
+func ToRoute(routeRequest *RouteRequest,
+ arr *ApisixRouteRequest,
+ u4 uuid.UUID,
+ resp *ApisixRouteResponse) (*Route, *errno.ManagerError) {
+ rd := &Route{}
+ if err := rd.Parse(routeRequest, arr); err != nil {
+ e := errno.FromMessage(errno.DBRouteCreateError, err.Error())
+ return nil, e
+ }
+ if rd.Name == "" {
+ rd.Name = routeRequest.Name
+ }
+ rd.ID = u4
+ // content_admin_api
+ if respStr, err := json.Marshal(resp); err != nil {
+ e := errno.FromMessage(errno.DBRouteCreateError, err.Error())
+ return nil, e
+ } else {
+ rd.ContentAdminApi = string(respStr)
+ }
+ // hosts
+ hosts := resp.Node.Value.Hosts
+ if hb, err := json.Marshal(hosts); err != nil {
+ e := errno.FromMessage(errno.DBRouteCreateError, err.Error())
+ logger.Warn(e.Msg)
+ } else {
+ rd.Hosts = string(hb)
+ }
+ // uris
+ uris := resp.Node.Value.Uris
+ if ub, err := json.Marshal(uris); err != nil {
+ e := errno.FromMessage(errno.DBRouteCreateError, err.Error())
+ logger.Warn(e.Msg)
+ } else {
+ rd.Uris = string(ub)
+ }
+ // upstreamNodes
+ if resp.Node.Value.Upstream != nil {
+ nodes := resp.Node.Value.Upstream.Nodes
+ ips := make([]string, 0)
+ for k, _ := range nodes {
+ ips = append(ips, k)
+ }
+ if nb, err := json.Marshal(ips); err != nil {
+ e := errno.FromMessage(errno.DBRouteCreateError, err.Error())
+ logger.Warn(e.Msg)
+ } else {
+ rd.UpstreamNodes = string(nb)
+ }
+ }
+ return rd, nil
+}
diff --git a/api/service/route_test.go b/api/service/route_test.go
new file mode 100644
index 0000000..82652ce
--- /dev/null
+++ b/api/service/route_test.go
@@ -0,0 +1,139 @@
+/*
+ * 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.
+ */
+package service
+
+import (
+ "encoding/json"
+ "github.com/stretchr/testify/assert"
+ "testing"
+)
+
+func TestRedirectMarshal(t *testing.T) {
+ a := assert.New(t)
+ r := &Redirect{
+ HttpToHttps: true,
+ Code: 302,
+ Uri: "/hello",
+ }
+ if result, err := json.Marshal(r); err != nil {
+ t.Error(err.Error())
+ } else {
+ h := make(map[string]interface{})
+ json.Unmarshal(result, &h)
+ a.Equal(1, len(h))
+ a.Equal(nil, h["code"])
+ a.Equal(true, h["http_to_https"])
+ }
+}
+
+func TestToApisixRequest_RediretPlugins(t *testing.T) {
+ rr := &RouteRequest{
+ ID: "u guess a uuid",
+ Name: "a special name",
+ Desc: "any description",
+ Priority: 0,
+ Methods: []string{"GET"},
+ Uris: []string{},
+ Hosts: []string{"www.baidu.com"},
+ Protocols: []string{"http", "https", "websocket"},
+ Redirect: &Redirect{HttpToHttps: true, Code: 200, Uri: "/hello"},
+ Vars: [][]string{},
+ }
+ ar := ToApisixRequest(rr)
+ a := assert.New(t)
+ a.Equal(1, len(ar.Plugins))
+ a.NotEqual(nil, ar.Plugins["redirect"])
+}
+
+func TestToApisixRequest_Vars(t *testing.T) {
+ rr := &RouteRequest{
+ ID: "u guess a uuid",
+ Name: "a special name",
+ Desc: "any description",
+ Priority: 0,
+ Methods: []string{"GET"},
+ Uris: []string{},
+ Hosts: []string{"www.baidu.com"},
+ Protocols: []string{"http", "https", "websocket"},
+ Redirect: &Redirect{HttpToHttps: true, Code: 200, Uri: "/hello"},
+ Vars: [][]string{},
+ }
+ ar := ToApisixRequest(rr)
+ a := assert.New(t)
+ b, err := json.Marshal(ar)
+ a.Equal(nil, err)
+
+ m := make(map[string]interface{})
+ err = json.Unmarshal(b, &m)
+ a.Equal(nil, err)
+ t.Log(m["vars"])
+ a.Equal(nil, m["vars"])
+}
+
+func TestToApisixRequest_Upstream(t *testing.T) {
+ nodes := make(map[string]int64)
+ nodes["127.0.0.1:8080"] = 100
+ upstream := &Upstream{
+ UType: "roundrobin",
+ Nodes: nodes,
+ Timeout: UpstreamTimeout{15, 15, 15},
+ }
+ rr := &RouteRequest{
+ ID: "u guess a uuid",
+ Name: "a special name",
+ Desc: "any description",
+ Priority: 0,
+ Methods: []string{"GET"},
+ Uris: []string{},
+ Hosts: []string{"www.baidu.com"},
+ Protocols: []string{"http", "https", "websocket"},
+ Redirect: &Redirect{HttpToHttps: true, Code: 200, Uri: "/hello"},
+ Vars: [][]string{},
+ Upstream: upstream,
+ }
+ ar := ToApisixRequest(rr)
+ a := assert.New(t)
+ a.Equal("roundrobin", ar.Upstream.UType)
+ a.Equal(true, ar.Upstream.EnableWebsocket)
+}
+
+func TestToApisixRequest_UpstreamUnable(t *testing.T) {
+ nodes := make(map[string]int64)
+ nodes["127.0.0.1:8080"] = 100
+ upstream := &Upstream{
+ UType: "roundrobin",
+ Nodes: nodes,
+ Timeout: UpstreamTimeout{15, 15, 15},
+ }
+ rr := &RouteRequest{
+ ID: "u guess a uuid",
+ Name: "a special name",
+ Desc: "any description",
+ Priority: 0,
+ Methods: []string{"GET"},
+ Uris: []string{},
+ Hosts: []string{"www.baidu.com"},
+ Protocols: []string{"http", "https"},
+ Redirect: &Redirect{HttpToHttps: true, Code: 200, Uri: "/hello"},
+ Vars: [][]string{},
+ Upstream: upstream,
+ }
+ ar := ToApisixRequest(rr)
+ a := assert.New(t)
+ a.Equal("roundrobin", ar.Upstream.UType)
+ a.Equal(false, ar.Upstream.EnableWebsocket)
+}
diff --git a/api/service/ssl.go b/api/service/ssl.go
new file mode 100644
index 0000000..839e3b8
--- /dev/null
+++ b/api/service/ssl.go
@@ -0,0 +1,300 @@
+/*
+ * 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.
+ */
+package service
+
+import (
+ "crypto/tls"
+ "crypto/x509"
+ "encoding/json"
+ "encoding/pem"
+ "errors"
+ "fmt"
+
+ "github.com/satori/go.uuid"
+
+ "github.com/apisix/manager-api/conf"
+ "github.com/apisix/manager-api/errno"
+ "github.com/apisix/manager-api/utils"
+)
+
+type Ssl struct {
+ Base
+ ValidityStart uint64 `json:"validity_start"`
+ ValidityEnd uint64 `json:"validity_end"`
+ Snis string `json:"snis"`
+ Status uint64 `json:"status"`
+ PublicKey string `json:"public_key,omitempty"`
+}
+
+type SslDto struct {
+ Base
+ ValidityStart uint64 `json:"validity_start"`
+ ValidityEnd uint64 `json:"validity_end"`
+ Snis []string `json:"snis"`
+ Status uint64 `json:"status"`
+ PublicKey string `json:"public_key,omitempty"`
+}
+
+type SslRequest struct {
+ ID string `json:"id,omitempty"`
+ PublicKey string `json:"cert"`
+ PrivateKey string `json:"key"`
+ Snis []string `json:"snis"`
+}
+
+// ApisixSslResponse is response from apisix admin api
+type ApisixSslResponse struct {
+ Action string `json:"action"`
+ Node *SslNode `json:"node"`
+}
+
+type SslNode struct {
+ Value SslRequest `json:"value"`
+ ModifiedIndex uint64 `json:"modifiedIndex"`
+}
+
+func (req *SslRequest) Parse(body interface{}) {
+ if err := json.Unmarshal(body.([]byte), req); err != nil {
+ req = nil
+ logger.Error(errno.FromMessage(errno.RouteRequestError, err.Error()).Msg)
+ }
+}
+
+func (sslDto *SslDto) Parse(ssl *Ssl) error {
+ sslDto.ID = ssl.ID
+ sslDto.ValidityStart = ssl.ValidityStart
+ sslDto.ValidityEnd = ssl.ValidityEnd
+
+ var snis []string
+ _ = json.Unmarshal([]byte(ssl.Snis), &snis)
+ sslDto.Snis = snis
+
+ sslDto.Status = ssl.Status
+ sslDto.PublicKey = ssl.PublicKey
+ sslDto.CreateTime = ssl.CreateTime
+ sslDto.UpdateTime = ssl.UpdateTime
+
+ return nil
+}
+
+func SslList(page, size int) ([]byte, error) {
+ var count int
+ sslList := []Ssl{}
+ if err := conf.DB().Table("ssls").Offset((page - 1) * size).Limit(size).Find(&sslList).Count(&count).Error; err != nil {
+ return nil, err
+ }
+
+ sslDtoList := []SslDto{}
+
+ for _, ssl := range sslList {
+ sslDto := SslDto{}
+ sslDto.Parse(&ssl)
+
+ sslDtoList = append(sslDtoList, sslDto)
+ }
+
+ data := errno.FromMessage(errno.SystemSuccess).ListResponse(count, sslDtoList)
+
+ return json.Marshal(data)
+}
+
+func SslItem(id string) ([]byte, error) {
+ ssl := &Ssl{}
+ if err := conf.DB().Table("ssls").Where("id = ?", id).First(ssl).Error; err != nil {
+ return nil, err
+ }
+
+ sslDto := &SslDto{}
+ sslDto.Parse(ssl)
+
+ data := errno.FromMessage(errno.SystemSuccess).ItemResponse(sslDto)
+
+ return json.Marshal(data)
+}
+
+func SslCheck(param interface{}) ([]byte, error) {
+ sslReq := &SslRequest{}
+ sslReq.Parse(param)
+
+ ssl, err := ParseCert(sslReq.PublicKey, sslReq.PrivateKey)
+
+ if err != nil {
+ return nil, err
+ }
+
+ ssl.PublicKey = ""
+
+ sslDto := &SslDto{}
+ sslDto.Parse(ssl)
+
+ data := errno.FromMessage(errno.SystemSuccess).ItemResponse(sslDto)
+
+ return json.Marshal(data)
+}
+
+func SslCreate(param interface{}, id string) error {
+ sslReq := &SslRequest{}
+ sslReq.Parse(param)
+
+ ssl, err := ParseCert(sslReq.PublicKey, sslReq.PrivateKey)
+
+ if err != nil {
+ return err
+ }
+
+ // first admin api
+ var snis []string
+ _ = json.Unmarshal([]byte(ssl.Snis), &snis)
+ sslReq.Snis = snis
+
+ if _, err := sslReq.PutToApisix(id); err != nil {
+ return err
+ }
+ // then mysql
+ ssl.ID = uuid.FromStringOrNil(id)
+ if err := conf.DB().Create(ssl).Error; err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func SslUpdate(param interface{}, id string) error {
+ sslReq := &SslRequest{}
+ sslReq.Parse(param)
+
+ ssl, err := ParseCert(sslReq.PublicKey, sslReq.PrivateKey)
+
+ if err != nil {
+ return err
+ }
+
+ // first admin api
+ var snis []string
+ _ = json.Unmarshal([]byte(ssl.Snis), &snis)
+ sslReq.Snis = snis
+
+ if _, err := sslReq.PutToApisix(id); err != nil {
+ return err
+ }
+
+ // then mysql
+ ssl.ID = uuid.FromStringOrNil(id)
+ data := Ssl{PublicKey: ssl.PublicKey, Snis: ssl.Snis, ValidityStart: ssl.ValidityStart, ValidityEnd: ssl.ValidityEnd}
+ if err := conf.DB().Model(&ssl).Updates(data).Error; err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func SslDelete(id string) error {
+ // delete from apisix
+ request := &SslRequest{}
+ request.ID = id
+ if _, err := request.DeleteFromApisix(); err != nil {
+ return err
+ }
+ // delete from mysql
+ ssl := &Ssl{}
+ ssl.ID = uuid.FromStringOrNil(id)
+ if err := conf.DB().Delete(ssl).Error; err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (req *SslRequest) PutToApisix(rid string) (*ApisixSslResponse, error) {
+ url := fmt.Sprintf("%s/ssl/%s", conf.BaseUrl, rid)
+ if data, err := json.Marshal(req); err != nil {
+ return nil, err
+ } else {
+ if resp, err := utils.Put(url, data); err != nil {
+ logger.Error(url)
+ logger.Error(string(data))
+ logger.Error(err.Error())
+ return nil, err
+ } else {
+ var arresp ApisixSslResponse
+ if err := json.Unmarshal(resp, &arresp); err != nil {
+ logger.Error(err.Error())
+ return nil, err
+ } else {
+ return &arresp, nil
+ }
+ }
+ }
+}
+
+func (req *SslRequest) DeleteFromApisix() (*ApisixSslResponse, error) {
+ id := req.ID
+ url := fmt.Sprintf("%s/ssl/%s", conf.BaseUrl, id)
+
+ if resp, err := utils.Delete(url); err != nil {
+ logger.Error(err.Error())
+ return nil, err
+ } else {
+ var arresp ApisixSslResponse
+ if err := json.Unmarshal(resp, &arresp); err != nil {
+ logger.Error(err.Error())
+ return nil, err
+ } else {
+ return &arresp, nil
+ }
+ }
+}
+
+func ParseCert(crt, key string) (*Ssl, error) {
+ // print private key
+ certDERBlock, _ := pem.Decode([]byte(crt))
+ if certDERBlock == nil {
+ return nil, errors.New("Certificate resolution failed")
+ }
+ // match
+ _, err := tls.X509KeyPair([]byte(crt), []byte(key))
+ if err != nil {
+ return nil, err
+ }
+
+ x509Cert, err := x509.ParseCertificate(certDERBlock.Bytes)
+
+ if err != nil {
+ return nil, errors.New("Certificate resolution failed")
+ } else {
+ ssl := Ssl{}
+
+ //domain
+ snis := []byte{}
+ if x509Cert.DNSNames == nil || len(x509Cert.DNSNames) < 1 {
+ tmp := []string{}
+ if x509Cert.Subject.CommonName != "" {
+ tmp = []string{x509Cert.Subject.CommonName}
+ }
+ snis, _ = json.Marshal(tmp)
+ } else {
+ snis, _ = json.Marshal(x509Cert.DNSNames)
+ }
+ ssl.Snis = string(snis)
+
+ ssl.ValidityStart = uint64(x509Cert.NotBefore.Unix())
+ ssl.ValidityEnd = uint64(x509Cert.NotAfter.Unix())
+ ssl.PublicKey = crt
+
+ return &ssl, nil
+ }
+}
diff --git a/api/utils/copy.go b/api/utils/copy.go
new file mode 100644
index 0000000..30f228a
--- /dev/null
+++ b/api/utils/copy.go
@@ -0,0 +1,41 @@
+/*
+ * 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.
+ */
+package utils
+
+func CopyMap(origin map[string]interface{}) map[string]interface{} {
+ result := make(map[string]interface{})
+ for k, v := range origin {
+ result[k] = v
+ }
+ return result
+}
+
+func CopyStrings(origin [][]string) [][]string {
+ result := make([][]string, 0)
+ for _, s := range origin {
+ result = append(result, s)
+ }
+ return result
+}
+
+func Set2Map(origin []string) map[string]int {
+ result := make(map[string]int)
+ for _, s := range origin {
+ result[s] = 1
+ }
+ return result
+}
diff --git a/api/utils/http.go b/api/utils/http.go
new file mode 100644
index 0000000..119cf0b
--- /dev/null
+++ b/api/utils/http.go
@@ -0,0 +1,113 @@
+/*
+ * 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.
+ */
+package utils
+
+import (
+ "fmt"
+ "github.com/apisix/manager-api/conf"
+ "github.com/apisix/manager-api/log"
+ "gopkg.in/resty.v1"
+ "net/http"
+ "time"
+)
+
+const timeout = 3000
+
+var logger = log.GetLogger()
+
+func Get(url string) ([]byte, error) {
+ r := resty.New().
+ SetTimeout(time.Duration(timeout)*time.Millisecond).
+ R().
+ SetHeader("content-type", "application/json").
+ SetHeader("X-API-KEY", conf.ApiKey)
+ resp, err := r.Get(url)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode() != http.StatusOK {
+ return nil, fmt.Errorf("status: %d, body: %s", resp.StatusCode(), resp.Body())
+ }
+ return resp.Body(), nil
+}
+
+func Post(url string, bytes []byte) ([]byte, error) {
+ r := resty.New().
+ SetTimeout(time.Duration(timeout)*time.Millisecond).
+ R().
+ SetHeader("content-type", "application/json").
+ SetHeader("X-API-KEY", conf.ApiKey)
+ r.SetBody(bytes)
+ resp, err := r.Post(url)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusCreated {
+ return nil, fmt.Errorf("status: %d, body: %s", resp.StatusCode(), resp.Body())
+ }
+ return resp.Body(), nil
+}
+
+func Put(url string, bytes []byte) ([]byte, error) {
+ r := resty.New().
+ SetTimeout(time.Duration(timeout)*time.Millisecond).
+ R().
+ SetHeader("content-type", "application/json").
+ SetHeader("X-API-KEY", conf.ApiKey)
+ r.SetBody(bytes)
+ resp, err := r.Put(url)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusCreated {
+ return nil, fmt.Errorf("status: %d, body: %s", resp.StatusCode(), resp.Body())
+ }
+ return resp.Body(), nil
+}
+
+func Patch(url string, bytes []byte) ([]byte, error) {
+ r := resty.New().
+ SetTimeout(time.Duration(timeout)*time.Millisecond).
+ R().
+ SetHeader("content-type", "application/json").
+ SetHeader("X-API-KEY", conf.ApiKey)
+ r.SetBody(bytes)
+ resp, err := r.Patch(url)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode() != http.StatusOK {
+ return nil, fmt.Errorf("status: %d, body: %s", resp.StatusCode(), resp.Body())
+ }
+ return resp.Body(), nil
+}
+
+func Delete(url string) ([]byte, error) {
+ r := resty.New().
+ SetTimeout(time.Duration(timeout)*time.Millisecond).
+ R().
+ SetHeader("content-type", "application/json").
+ SetHeader("X-API-KEY", conf.ApiKey)
+ resp, err := r.Delete(url)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode() != http.StatusOK {
+ return nil, fmt.Errorf("status: %d, body: %s", resp.StatusCode(), resp.Body())
+ }
+ return resp.Body(), nil
+}
diff --git a/compose/README.md b/compose/README.md
new file mode 100644
index 0000000..7d7c7ce
--- /dev/null
+++ b/compose/README.md
@@ -0,0 +1,56 @@
+## Deploy
+
+```sh
+$ cd incubator-apisix-dashboard/compose
+
+$ chmod +x ./manager_conf/build.sh
+
+$ docker-compose -p dashboard up -d
+```
+
+## Usage
+
+### 1. login dashboard
+
+Visit `http://127.0.0.1/dashboard/` in the browser,
+Enter `http://127.0.0.1:8080/apisix/admin` into the first input box, this is the backend management service address
+
+![login](pics/login.png)
+
+now, click `save`.
+
+### 2. If you want to display the grafana metric dashboard, please fill in the grafana shared link as follows
+
+1.get grafana shared link
+
+Visit `http://127.0.0.1:3000/?search=open&orgId=1`
+
+![login](pics/grafana_1.png)
+
+click `Apache APISIX` dashboard, and you can see the page as follow
+
+![login](pics/grafana_2.png)
+
+click the button `shard dashboard` on the right of `Apache APISIX`
+
+![login](pics/grafana_3.png)
+
+copy the link, and then return to dashboard on the step 1
+
+![login](pics/grafana_4.png)
+
+click metric on the left, and then the config button
+
+Paste shared link
+
+![login](pics/grafana_5.png)
+
+save, and you can see the metrics
+
+![login](pics/grafana_6.png)
+
+
+
+
+
+
diff --git a/compose/apisix_conf/config.yaml b/compose/apisix_conf/config.yaml
new file mode 100644
index 0000000..bbdc65b
--- /dev/null
+++ b/compose/apisix_conf/config.yaml
@@ -0,0 +1,137 @@
+apisix:
+ node_listen: 9080 # APISIX listening port
+ enable_heartbeat: true
+ enable_admin: true
+ enable_admin_cors: true # Admin API support CORS response headers.
+ enable_debug: false
+ enable_dev_mode: false # Sets nginx worker_processes to 1 if set to true
+ enable_reuseport: true # Enable nginx SO_REUSEPORT switch if set to true.
+ enable_ipv6: true
+ config_center: etcd # etcd: use etcd to store the config value
+ # yaml: fetch the config value from local yaml file `/your_path/conf/apisix.yaml`
+
+ #proxy_protocol: # Proxy Protocol configuration
+ # listen_http_port: 9181 # The port with proxy protocol for http, it differs from node_listen and port_admin.
+ # This port can only receive http request with proxy protocol, but node_listen & port_admin
+ # can only receive http request. If you enable proxy protocol, you must use this port to
+ # receive http request with proxy protocol
+ # listen_https_port: 9182 # The port with proxy protocol for https
+ # enable_tcp_pp: true # Enable the proxy protocol for tcp proxy, it works for stream_proxy.tcp option
+ # enable_tcp_pp_to_upstream: true # Enables the proxy protocol to the upstream server
+
+ proxy_cache: # Proxy Caching configuration
+ cache_ttl: 10s # The default caching time if the upstream does not specify the cache time
+ zones: # The parameters of a cache
+ - name: disk_cache_one # The name of the cache, administrator can be specify
+ # which cache to use by name in the admin api
+ memory_size: 50m # The size of shared memory, it's used to store the cache index
+ disk_size: 1G # The size of disk, it's used to store the cache data
+ disk_path: "/tmp/disk_cache_one" # The path to store the cache data
+ cache_levels: "1:2" # The hierarchy levels of a cache
+ # - name: disk_cache_two
+ # memory_size: 50m
+ # disk_size: 1G
+ # disk_path: "/tmp/disk_cache_two"
+ # cache_levels: "1:2"
+
+# allow_admin: # http://nginx.org/en/docs/http/ngx_http_access_module.html#allow
+# - 127.0.0.0/24 # If we don't set any IP list, then any IP access is allowed by default.
+# - 172.17.0.0/24
+ # - "::/64"
+ # port_admin: 9180 # use a separate port
+
+ # Default token when use API to call for Admin API.
+ # *NOTE*: Highly recommended to modify this value to protect APISIX's Admin API.
+ # Disabling this configuration item means that the Admin API does not
+ # require any authentication.
+ admin_key:
+ -
+ name: "admin"
+ key: edd1c9f034335f136f87ad84b625c8f1
+ role: admin # admin: manage all configuration data
+ # viewer: only can view configuration data
+ -
+ name: "viewer"
+ key: 4054f7cf07e344346cd3f287985e76a2
+ role: viewer
+ router:
+ http: 'radixtree_uri' # radixtree_uri: match route by uri(base on radixtree)
+ # radixtree_host_uri: match route by host + uri(base on radixtree)
+ ssl: 'radixtree_sni' # radixtree_sni: match route by SNI(base on radixtree)
+ # stream_proxy: # TCP/UDP proxy
+ # tcp: # TCP proxy port list
+ # - 9100
+ # - 9101
+ # udp: # UDP proxy port list
+ # - 9200
+ # - 9211
+ dns_resolver: # default DNS resolver, with disable IPv6 and enable local DNS
+ - 127.0.0.11
+ - 114.114.114.114
+ - 223.5.5.5
+ - 1.1.1.1
+ - 8.8.8.8
+ dns_resolver_valid: 30 # valid time for dns result 30 seconds
+ resolver_timeout: 5 # resolver timeout
+ ssl:
+ enable: true
+ enable_http2: true
+ listen_port: 9443
+ ssl_protocols: "TLSv1 TLSv1.1 TLSv1.2 TLSv1.3"
+ ssl_ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES2 [...]
+
+nginx_config: # config for render the template to genarate nginx.conf
+ error_log: "logs/error.log"
+ error_log_level: "warn" # warn,error
+ worker_rlimit_nofile: 20480 # the number of files a worker process can open, should be larger than worker_connections
+ event:
+ worker_connections: 10620
+ http:
+ access_log: "logs/access.log"
+ keepalive_timeout: 60s # timeout during which a keep-alive client connection will stay open on the server side.
+ client_header_timeout: 60s # timeout for reading client request header, then 408 (Request Time-out) error is returned to the client
+ client_body_timeout: 60s # timeout for reading client request body, then 408 (Request Time-out) error is returned to the client
+ send_timeout: 10s # timeout for transmitting a response to the client.then the connection is closed
+ underscores_in_headers: "on" # default enables the use of underscores in client request header fields
+ real_ip_header: "X-Real-IP" # http://nginx.org/en/docs/http/ngx_http_realip_module.html#real_ip_header
+ real_ip_from: # http://nginx.org/en/docs/http/ngx_http_realip_module.html#set_real_ip_from
+ - 127.0.0.1
+ - 'unix:'
+ #lua_shared_dicts: # add custom shared cache to nginx.conf
+ # ipc_shared_dict: 100m # custom shared cache, format: `cache-key: cache-size`
+
+etcd:
+ host: # it's possible to define multiple etcd hosts addresses of the same etcd cluster.
+ - "http://192.17.5.10:2379" # multiple etcd address
+ prefix: "/apisix" # apisix configurations prefix
+ timeout: 3 # 3 seconds
+
+plugins: # plugin list
+ - example-plugin
+ - limit-req
+ - limit-count
+ - limit-conn
+ - key-auth
+ - basic-auth
+ - prometheus
+ - node-status
+ - jwt-auth
+ - zipkin
+ - ip-restriction
+ - grpc-transcode
+ - serverless-pre-function
+ - serverless-post-function
+ - openid-connect
+ - proxy-rewrite
+ - redirect
+ - response-rewrite
+ - fault-injection
+ - udp-logger
+ - wolf-rbac
+ - proxy-cache
+ - tcp-logger
+ - proxy-mirror
+ - kafka-logger
+ - cors
+stream_plugins:
+ - mqtt-proxy
diff --git a/compose/docker-compose.yml b/compose/docker-compose.yml
new file mode 100644
index 0000000..859e390
--- /dev/null
+++ b/compose/docker-compose.yml
@@ -0,0 +1,124 @@
+version: "3"
+
+services:
+ apisix:
+ image: apache/apisix:latest
+ restart: always
+ volumes:
+ - ./apisix_log:/usr/local/apisix/logs
+ - ./apisix_conf/config.yaml:/usr/local/apisix/conf/config.yaml:ro
+ depends_on:
+ - etcd
+ ##network_mode: host
+ ports:
+ - "9080:9080/tcp"
+ - "9443:9443/tcp"
+ networks:
+ apisix-dashboard:
+ ipv4_address: 192.17.5.11
+
+ etcd:
+ image: gcr.io/etcd-development/etcd:v3.3.12
+ command: /usr/local/bin/etcd --advertise-client-urls http://0.0.0.0:2379 --listen-client-urls http://0.0.0.0:2379
+ restart: always
+ volumes:
+ - ./etcd_data:/etcd_data
+ environment:
+ ETCD_DATA_DIR: /etcd_data
+ ports:
+ - "2379:2379/tcp"
+ networks:
+ apisix-dashboard:
+ ipv4_address: 192.17.5.10
+
+ web1:
+ image: ruby:2-alpine
+ command: sh -c "mkdir -p /tmp/www && echo 'web1' > /tmp/www/web1.txt && ruby -run -ehttpd /tmp/www -p8000"
+ restart: always
+ ports:
+ - "9081:8000/tcp"
+ networks:
+ apisix-dashboard:
+ ipv4_address: 192.17.5.12
+
+ web2:
+ image: ruby:2-alpine
+ command: sh -c "mkdir -p /tmp/www && echo 'web2' > /tmp/www/web2.txt && ruby -run -ehttpd /tmp/www -p8000"
+ restart: always
+ ports:
+ - "9082:8000/tcp"
+ networks:
+ apisix-dashboard:
+ ipv4_address: 192.17.5.13
+
+ mysql:
+ image: mysql:latest
+ restart: always
+ ports:
+ - "3309:3306/tcp"
+ environment:
+ - MYSQL_ROOT_PASSWORD=123456
+ volumes:
+ - /tmp/datadir:/var/lib/mysql
+ - /tmp/conf.d:/etc/mysql/conf.d
+ - ../api/script/db:/docker-entrypoint-initdb.d
+ networks:
+ apisix-dashboard:
+ ipv4_address: 192.17.5.14
+
+ manager:
+ build:
+ context: ./../api
+ dockerfile: Dockerfile
+ restart: always
+ ports:
+ - "8080:8080/tcp"
+ environment:
+ - ENV=prod
+ volumes:
+ - ./manager_conf/build.sh:/root/manager-api/build.sh
+ networks:
+ apisix-dashboard:
+ ipv4_address: 192.17.5.15
+
+ prometheus:
+ image: prom/prometheus
+ hostname: prometheus
+ restart: always
+ volumes:
+ - ./prometheus_conf/prometheus.yml:/etc/prometheus/prometheus.yml
+ ports:
+ - "9090:9090"
+ networks:
+ apisix-dashboard:
+ ipv4_address: 192.17.5.16
+
+ grafana:
+ image: grafana/grafana
+ container_name: grafana
+ hostname: grafana
+ restart: always
+ ports:
+ - "3000:3000"
+ volumes:
+ - "./grafana_conf/provisioning:/etc/grafana/provisioning"
+ - "./grafana_conf/dashboards:/var/lib/grafana/dashboards"
+ - "./grafana_conf/config/grafana.ini:/etc/grafana/grafana.ini"
+ networks:
+ apisix-dashboard:
+ ipv4_address: 192.17.5.17
+
+ dashboard:
+ build:
+ context: ./..
+ dockerfile: Dockerfile
+ restart: always
+ ports:
+ - "80:80/tcp"
+
+networks:
+ apisix-dashboard:
+ driver: bridge
+ ipam:
+ config:
+ - subnet: 192.17.0.0/16
\ No newline at end of file
diff --git a/compose/grafana_conf/config/grafana.ini b/compose/grafana_conf/config/grafana.ini
new file mode 100644
index 0000000..cb6a737
--- /dev/null
+++ b/compose/grafana_conf/config/grafana.ini
@@ -0,0 +1,756 @@
+##################### Grafana Configuration Example #####################
+#
+# Everything has defaults so you only need to uncomment things you want to
+# change
+
+# possible values : production, development
+;app_mode = production
+
+# instance name, defaults to HOSTNAME environment variable value or hostname if HOSTNAME var is empty
+;instance_name = ${HOSTNAME}
+
+#################################### Paths ####################################
+[paths]
+# Path to where grafana can store temp files, sessions, and the sqlite3 db (if that is used)
+;data = /var/lib/grafana
+
+# Temporary files in `data` directory older than given duration will be removed
+;temp_data_lifetime = 24h
+
+# Directory where grafana can store logs
+;logs = /var/log/grafana
+
+# Directory where grafana will automatically scan and look for plugins
+;plugins = /var/lib/grafana/plugins
+
+# folder that contains provisioning config files that grafana will apply on startup and while running.
+;provisioning = conf/provisioning
+
+#################################### Server ####################################
+[server]
+# Protocol (http, https, h2, socket)
+;protocol = http
+
+# The ip address to bind to, empty will bind to all interfaces
+;http_addr =
+
+# The http port to use
+;http_port = 3000
+
+# The public facing domain name used to access grafana from a browser
+;domain = localhost
+
+# Redirect to correct domain if host header does not match domain
+# Prevents DNS rebinding attacks
+;enforce_domain = false
+
+# The full public facing url you use in browser, used for redirects and emails
+# If you use reverse proxy and sub path specify full url (with sub path)
+;root_url = %(protocol)s://%(domain)s:%(http_port)s/
+
+# Serve Grafana from subpath specified in `root_url` setting. By default it is set to `false` for compatibility reasons.
+;serve_from_sub_path = false
+
+# Log web requests
+;router_logging = false
+
+# the path relative working path
+;static_root_path = public
+
+# enable gzip
+;enable_gzip = false
+
+# https certs & key file
+;cert_file =
+;cert_key =
+
+# Unix socket path
+;socket =
+
+#################################### Database ####################################
+[database]
+# You can configure the database connection by specifying type, host, name, user and password
+# as separate properties or as on string using the url properties.
+
+# Either "mysql", "postgres" or "sqlite3", it's your choice
+;type = sqlite3
+;host = 127.0.0.1:3306
+;name = grafana
+;user = root
+# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;"""
+;password =
+
+# Use either URL or the previous fields to configure the database
+# Example: mysql://user:secret@host:port/database
+;url =
+
+# For "postgres" only, either "disable", "require" or "verify-full"
+;ssl_mode = disable
+
+;ca_cert_path =
+;client_key_path =
+;client_cert_path =
+;server_cert_name =
+
+# For "sqlite3" only, path relative to data_path setting
+;path = grafana.db
+
+# Max idle conn setting default is 2
+;max_idle_conn = 2
+
+# Max conn setting default is 0 (mean not set)
+;max_open_conn =
+
+# Connection Max Lifetime default is 14400 (means 14400 seconds or 4 hours)
+;conn_max_lifetime = 14400
+
+# Set to true to log the sql calls and execution times.
+;log_queries =
+
+# For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
+;cache_mode = private
+
+#################################### Cache server #############################
+[remote_cache]
+# Either "redis", "memcached" or "database" default is "database"
+;type = database
+
+# cache connectionstring options
+# database: will use Grafana primary database.
+# redis: config like redis server e.g. `addr=127.0.0.1:6379,pool_size=100,db=0,ssl=false`. Only addr is required. ssl may be 'true', 'false', or 'insecure'.
+# memcache: 127.0.0.1:11211
+;connstr =
+
+#################################### Data proxy ###########################
+[dataproxy]
+
+# This enables data proxy logging, default is false
+;logging = false
+
+# How long the data proxy should wait before timing out default is 30 (seconds)
+;timeout = 30
+
+# If enabled and user is not anonymous, data proxy will add X-Grafana-User header with username into the request, default is false.
+;send_user_header = false
+
+#################################### Analytics ####################################
+[analytics]
+# Server reporting, sends usage counters to stats.grafana.org every 24 hours.
+# No ip addresses are being tracked, only simple counters to track
+# running instances, dashboard and error counts. It is very helpful to us.
+# Change this option to false to disable reporting.
+;reporting_enabled = true
+
+# Set to false to disable all checks to https://grafana.net
+# for new vesions (grafana itself and plugins), check is used
+# in some UI views to notify that grafana or plugin update exists
+# This option does not cause any auto updates, nor send any information
+# only a GET request to http://grafana.com to get latest versions
+;check_for_updates = true
+
+# Google Analytics universal tracking code, only enabled if you specify an id here
+;google_analytics_ua_id =
+
+# Google Tag Manager ID, only enabled if you specify an id here
+;google_tag_manager_id =
+
+#################################### Security ####################################
+[security]
+# disable creation of admin user on first start of grafana
+;disable_initial_admin_creation = false
+
+# default admin user, created on startup
+;admin_user = admin
+
+# default admin password, can be changed before first start of grafana, or in profile settings
+;admin_password = admin
+
+# used for signing
+;secret_key = SW2YcwTIb9zpOOhoPsMm
+
+# disable gravatar profile images
+;disable_gravatar = false
+
+# data source proxy whitelist (ip_or_domain:port separated by spaces)
+;data_source_proxy_whitelist =
+
+# disable protection against brute force login attempts
+;disable_brute_force_login_protection = false
+
+# set to true if you host Grafana behind HTTPS. default is false.
+;cookie_secure = false
+
+# set cookie SameSite attribute. defaults to `lax`. can be set to "lax", "strict", "none" and "disabled"
+;cookie_samesite = none
+
+# set to true if you want to allow browsers to render Grafana in a <frame>, <iframe>, <embed> or <object>. default is false.
+allow_embedding = true
+
+# Set to true if you want to enable http strict transport security (HSTS) response header.
+# This is only sent when HTTPS is enabled in this configuration.
+# HSTS tells browsers that the site should only be accessed using HTTPS.
+# The default version will change to true in the next minor release, 6.3.
+;strict_transport_security = false
+
+# Sets how long a browser should cache HSTS. Only applied if strict_transport_security is enabled.
+;strict_transport_security_max_age_seconds = 86400
+
+# Set to true if to enable HSTS preloading option. Only applied if strict_transport_security is enabled.
+;strict_transport_security_preload = false
+
+# Set to true if to enable the HSTS includeSubDomains option. Only applied if strict_transport_security is enabled.
+;strict_transport_security_subdomains = false
+
+# Set to true to enable the X-Content-Type-Options response header.
+# The X-Content-Type-Options response HTTP header is a marker used by the server to indicate that the MIME types advertised
+# in the Content-Type headers should not be changed and be followed. The default will change to true in the next minor release, 6.3.
+;x_content_type_options = false
+
+# Set to true to enable the X-XSS-Protection header, which tells browsers to stop pages from loading
+# when they detect reflected cross-site scripting (XSS) attacks. The default will change to true in the next minor release, 6.3.
+;x_xss_protection = false
+
+#################################### Snapshots ###########################
+[snapshots]
+# snapshot sharing options
+;external_enabled = true
+;external_snapshot_url = https://snapshots-origin.raintank.io
+;external_snapshot_name = Publish to snapshot.raintank.io
+
+# Set to true to enable this Grafana instance act as an external snapshot server and allow unauthenticated requests for
+# creating and deleting snapshots.
+;public_mode = false
+
+# remove expired snapshot
+;snapshot_remove_expired = true
+
+#################################### Dashboards History ##################
+[dashboards]
+# Number dashboard versions to keep (per dashboard). Default: 20, Minimum: 1
+;versions_to_keep = 20
+
+# Minimum dashboard refresh interval. When set, this will restrict users to set the refresh interval of a dashboard lower than given interval. Per default this is 5 seconds.
+# The interval string is a possibly signed sequence of decimal numbers, followed by a unit suffix (ms, s, m, h, d), e.g. 30s or 1m.
+;min_refresh_interval = 5s
+
+#################################### Users ###############################
+[users]
+# disable user signup / registration
+;allow_sign_up = true
+
+# Allow non admin users to create organizations
+;allow_org_create = true
+
+# Set to true to automatically assign new users to the default organization (id 1)
+;auto_assign_org = true
+
+# Set this value to automatically add new users to the provided organization (if auto_assign_org above is set to true)
+;auto_assign_org_id = 1
+
+# Default role new users will be automatically assigned (if disabled above is set to true)
+;auto_assign_org_role = Viewer
+
+# Require email validation before sign up completes
+;verify_email_enabled = false
+
+# Background text for the user field on the login page
+;login_hint = email or username
+;password_hint = password
+
+# Default UI theme ("dark" or "light")
+;default_theme = dark
+
+# External user management, these options affect the organization users view
+;external_manage_link_url =
+;external_manage_link_name =
+;external_manage_info =
+
+# Viewers can edit/inspect dashboard settings in the browser. But not save the dashboard.
+;viewers_can_edit = false
+
+# Editors can administrate dashboard, folders and teams they create
+;editors_can_admin = false
+
+[auth]
+# Login cookie name
+;login_cookie_name = grafana_session
+
+# The lifetime (days) an authenticated user can be inactive before being required to login at next visit. Default is 7 days,
+;login_maximum_inactive_lifetime_days = 7
+
+# The maximum lifetime (days) an authenticated user can be logged in since login time before being required to login. Default is 30 days.
+;login_maximum_lifetime_days = 30
+
+# How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes.
+;token_rotation_interval_minutes = 10
+
+# Set to true to disable (hide) the login form, useful if you use OAuth, defaults to false
+;disable_login_form = false
+
+# Set to true to disable the signout link in the side menu. useful if you use auth.proxy, defaults to false
+;disable_signout_menu = false
+
+# URL to redirect the user to after sign out
+;signout_redirect_url =
+
+# Set to true to attempt login with OAuth automatically, skipping the login screen.
+# This setting is ignored if multiple OAuth providers are configured.
+;oauth_auto_login = false
+
+# OAuth state max age cookie duration. Defaults to 60 seconds.
+;oauth_state_cookie_max_age = 60
+
+# limit of api_key seconds to live before expiration
+;api_key_max_seconds_to_live = -1
+
+#################################### Anonymous Auth ######################
+[auth.anonymous]
+# enable anonymous access
+enabled = true
+
+# specify organization name that should be used for unauthenticated users
+;org_name = Main Org.
+
+# specify role for unauthenticated users
+;org_role = Viewer
+
+#################################### Github Auth ##########################
+[auth.github]
+;enabled = false
+;allow_sign_up = true
+;client_id = some_id
+;client_secret = some_secret
+;scopes = user:email,read:org
+;auth_url = https://github.com/login/oauth/authorize
+;token_url = https://github.com/login/oauth/access_token
+;api_url = https://api.github.com/user
+;allowed_domains =
+;team_ids =
+;allowed_organizations =
+
+#################################### GitLab Auth #########################
+[auth.gitlab]
+;enabled = false
+;allow_sign_up = true
+;client_id = some_id
+;client_secret = some_secret
+;scopes = api
+;auth_url = https://gitlab.com/oauth/authorize
+;token_url = https://gitlab.com/oauth/token
+;api_url = https://gitlab.com/api/v4
+;allowed_domains =
+;allowed_groups =
+
+#################################### Google Auth ##########################
+[auth.google]
+;enabled = false
+;allow_sign_up = true
+;client_id = some_client_id
+;client_secret = some_client_secret
+;scopes = https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email
+;auth_url = https://accounts.google.com/o/oauth2/auth
+;token_url = https://accounts.google.com/o/oauth2/token
+;api_url = https://www.googleapis.com/oauth2/v1/userinfo
+;allowed_domains =
+;hosted_domain =
+
+#################################### Grafana.com Auth ####################
+[auth.grafana_com]
+;enabled = false
+;allow_sign_up = true
+;client_id = some_id
+;client_secret = some_secret
+;scopes = user:email
+;allowed_organizations =
+
+#################################### Azure AD OAuth #######################
+[auth.azuread]
+;name = Azure AD
+;enabled = false
+;allow_sign_up = true
+;client_id = some_client_id
+;client_secret = some_client_secret
+;scopes = openid email profile
+;auth_url = https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/authorize
+;token_url = https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token
+;allowed_domains =
+;allowed_groups =
+
+#################################### Okta OAuth #######################
+[auth.okta]
+;name = Okta
+;enabled = false
+;allow_sign_up = true
+;client_id = some_id
+;client_secret = some_secret
+;scopes = openid profile email groups
+;auth_url = https://<tenant-id>.okta.com/oauth2/v1/authorize
+;token_url = https://<tenant-id>.okta.com/oauth2/v1/token
+;api_url = https://<tenant-id>.okta.com/oauth2/v1/userinfo
+;allowed_domains =
+;allowed_groups =
+;role_attribute_path =
+
+#################################### Generic OAuth ##########################
+[auth.generic_oauth]
+;enabled = false
+;name = OAuth
+;allow_sign_up = true
+;client_id = some_id
+;client_secret = some_secret
+;scopes = user:email,read:org
+;email_attribute_name = email:primary
+;email_attribute_path =
+;auth_url = https://foo.bar/login/oauth/authorize
+;token_url = https://foo.bar/login/oauth/access_token
+;api_url = https://foo.bar/user
+;allowed_domains =
+;team_ids =
+;allowed_organizations =
+;role_attribute_path =
+;tls_skip_verify_insecure = false
+;tls_client_cert =
+;tls_client_key =
+;tls_client_ca =
+
+#################################### Basic Auth ##########################
+[auth.basic]
+;enabled = true
+
+#################################### Auth Proxy ##########################
+[auth.proxy]
+;enabled = false
+;header_name = X-WEBAUTH-USER
+;header_property = username
+;auto_sign_up = true
+;sync_ttl = 60
+;whitelist = 192.168.1.1, 192.168.2.1
+;headers = Email:X-User-Email, Name:X-User-Name
+# Read the auth proxy docs for details on what the setting below enables
+;enable_login_token = false
+
+#################################### Auth LDAP ##########################
+[auth.ldap]
+;enabled = false
+;config_file = /etc/grafana/ldap.toml
+;allow_sign_up = true
+
+# LDAP backround sync (Enterprise only)
+# At 1 am every day
+;sync_cron = "0 0 1 * * *"
+;active_sync_enabled = true
+
+#################################### SMTP / Emailing ##########################
+[smtp]
+;enabled = false
+;host = localhost:25
+;user =
+# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;"""
+;password =
+;cert_file =
+;key_file =
+;skip_verify = false
+;from_address = admin@grafana.localhost
+;from_name = Grafana
+# EHLO identity in SMTP dialog (defaults to instance_name)
+;ehlo_identity = dashboard.example.com
+
+[emails]
+;welcome_email_on_sign_up = false
+;templates_pattern = emails/*.html
+
+#################################### Logging ##########################
+[log]
+# Either "console", "file", "syslog". Default is console and file
+# Use space to separate multiple modes, e.g. "console file"
+;mode = console file
+
+# Either "debug", "info", "warn", "error", "critical", default is "info"
+;level = info
+
+# optional settings to set different levels for specific loggers. Ex filters = sqlstore:debug
+;filters =
+
+# For "console" mode only
+[log.console]
+;level =
+
+# log line format, valid options are text, console and json
+;format = console
+
+# For "file" mode only
+[log.file]
+;level =
+
+# log line format, valid options are text, console and json
+;format = text
+
+# This enables automated log rotate(switch of following options), default is true
+;log_rotate = true
+
+# Max line number of single file, default is 1000000
+;max_lines = 1000000
+
+# Max size shift of single file, default is 28 means 1 << 28, 256MB
+;max_size_shift = 28
+
+# Segment log daily, default is true
+;daily_rotate = true
+
+# Expired days of log file(delete after max days), default is 7
+;max_days = 7
+
+[log.syslog]
+;level =
+
+# log line format, valid options are text, console and json
+;format = text
+
+# Syslog network type and address. This can be udp, tcp, or unix. If left blank, the default unix endpoints will be used.
+;network =
+;address =
+
+# Syslog facility. user, daemon and local0 through local7 are valid.
+;facility =
+
+# Syslog tag. By default, the process' argv[0] is used.
+;tag =
+
+#################################### Usage Quotas ########################
+[quota]
+; enabled = false
+
+#### set quotas to -1 to make unlimited. ####
+# limit number of users per Org.
+; org_user = 10
+
+# limit number of dashboards per Org.
+; org_dashboard = 100
+
+# limit number of data_sources per Org.
+; org_data_source = 10
+
+# limit number of api_keys per Org.
+; org_api_key = 10
+
+# limit number of orgs a user can create.
+; user_org = 10
+
+# Global limit of users.
+; global_user = -1
+
+# global limit of orgs.
+; global_org = -1
+
+# global limit of dashboards
+; global_dashboard = -1
+
+# global limit of api_keys
+; global_api_key = -1
+
+# global limit on number of logged in users.
+; global_session = -1
+
+#################################### Alerting ############################
+[alerting]
+# Disable alerting engine & UI features
+;enabled = true
+# Makes it possible to turn off alert rule execution but alerting UI is visible
+;execute_alerts = true
+
+# Default setting for new alert rules. Defaults to categorize error and timeouts as alerting. (alerting, keep_state)
+;error_or_timeout = alerting
+
+# Default setting for how Grafana handles nodata or null values in alerting. (alerting, no_data, keep_state, ok)
+;nodata_or_nullvalues = no_data
+
+# Alert notifications can include images, but rendering many images at the same time can overload the server
+# This limit will protect the server from render overloading and make sure notifications are sent out quickly
+;concurrent_render_limit = 5
+
+
+# Default setting for alert calculation timeout. Default value is 30
+;evaluation_timeout_seconds = 30
+
+# Default setting for alert notification timeout. Default value is 30
+;notification_timeout_seconds = 30
+
+# Default setting for max attempts to sending alert notifications. Default value is 3
+;max_attempts = 3
+
+# Makes it possible to enforce a minimal interval between evaluations, to reduce load on the backend
+;min_interval_seconds = 1
+
+#################################### Explore #############################
+[explore]
+# Enable the Explore section
+;enabled = true
+
+#################################### Internal Grafana Metrics ##########################
+# Metrics available at HTTP API Url /metrics
+[metrics]
+# Disable / Enable internal metrics
+;enabled = true
+# Graphite Publish interval
+;interval_seconds = 10
+# Disable total stats (stat_totals_*) metrics to be generated
+;disable_total_stats = false
+
+#If both are set, basic auth will be required for the metrics endpoint.
+; basic_auth_username =
+; basic_auth_password =
+
+# Send internal metrics to Graphite
+[metrics.graphite]
+# Enable by setting the address setting (ex localhost:2003)
+;address =
+;prefix = prod.grafana.%(instance_name)s.
+
+#################################### Grafana.com integration ##########################
+# Url used to import dashboards directly from Grafana.com
+[grafana_com]
+;url = https://grafana.com
+
+#################################### Distributed tracing ############
+[tracing.jaeger]
+# Enable by setting the address sending traces to jaeger (ex localhost:6831)
+;address = localhost:6831
+# Tag that will always be included in when creating new spans. ex (tag1:value1,tag2:value2)
+;always_included_tag = tag1:value1
+# Type specifies the type of the sampler: const, probabilistic, rateLimiting, or remote
+;sampler_type = const
+# jaeger samplerconfig param
+# for "const" sampler, 0 or 1 for always false/true respectively
+# for "probabilistic" sampler, a probability between 0 and 1
+# for "rateLimiting" sampler, the number of spans per second
+# for "remote" sampler, param is the same as for "probabilistic"
+# and indicates the initial sampling rate before the actual one
+# is received from the mothership
+;sampler_param = 1
+# Whether or not to use Zipkin propagation (x-b3- HTTP headers).
+;zipkin_propagation = false
+# Setting this to true disables shared RPC spans.
+# Not disabling is the most common setting when using Zipkin elsewhere in your infrastructure.
+;disable_shared_zipkin_spans = false
+
+#################################### External image storage ##########################
+[external_image_storage]
+# Used for uploading images to public servers so they can be included in slack/email messages.
+# you can choose between (s3, webdav, gcs, azure_blob, local)
+;provider =
+
+[external_image_storage.s3]
+;endpoint =
+;path_style_access =
+;bucket =
+;region =
+;path =
+;access_key =
+;secret_key =
+
+[external_image_storage.webdav]
+;url =
+;public_url =
+;username =
+;password =
+
+[external_image_storage.gcs]
+;key_file =
+;bucket =
+;path =
+
+[external_image_storage.azure_blob]
+;account_name =
+;account_key =
+;container_name =
+
+[external_image_storage.local]
+# does not require any configuration
+
+[rendering]
+# Options to configure a remote HTTP image rendering service, e.g. using https://github.com/grafana/grafana-image-renderer.
+# URL to a remote HTTP image renderer service, e.g. http://localhost:8081/render, will enable Grafana to render panels and dashboards to PNG-images using HTTP requests to an external service.
+;server_url =
+# If the remote HTTP image renderer service runs on a different server than the Grafana server you may have to configure this to a URL where Grafana is reachable, e.g. http://grafana.domain/.
+;callback_url =
+# Concurrent render request limit affects when the /render HTTP endpoint is used. Rendering many images at the same time can overload the server,
+# which this setting can help protect against by only allowing a certain amount of concurrent requests.
+;concurrent_render_request_limit = 30
+
+[panels]
+# If set to true Grafana will allow script tags in text panels. Not recommended as it enable XSS vulnerabilities.
+;disable_sanitize_html = false
+
+[plugins]
+;enable_alpha = false
+;app_tls_skip_verify_insecure = false
+# Enter a comma-separated list of plugin identifiers to identify plugins that are allowed to be loaded even if they lack a valid signature.
+;allow_loading_unsigned_plugins =
+
+#################################### Grafana Image Renderer Plugin ##########################
+[plugin.grafana-image-renderer]
+# Instruct headless browser instance to use a default timezone when not provided by Grafana, e.g. when rendering panel image of alert.
+# See ICU’s metaZones.txt (https://cs.chromium.org/chromium/src/third_party/icu/source/data/misc/metaZones.txt) for a list of supported
+# timezone IDs. Fallbacks to TZ environment variable if not set.
+;rendering_timezone =
+
+# Instruct headless browser instance to use a default language when not provided by Grafana, e.g. when rendering panel image of alert.
+# Please refer to the HTTP header Accept-Language to understand how to format this value, e.g. 'fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5'.
+;rendering_language =
+
+# Instruct headless browser instance to use a default device scale factor when not provided by Grafana, e.g. when rendering panel image of alert.
+# Default is 1. Using a higher value will produce more detailed images (higher DPI), but will require more disk space to store an image.
+;rendering_viewport_device_scale_factor =
+
+# Instruct headless browser instance whether to ignore HTTPS errors during navigation. Per default HTTPS errors are not ignored. Due to
+# the security risk it's not recommended to ignore HTTPS errors.
+;rendering_ignore_https_errors =
+
+# Instruct headless browser instance whether to capture and log verbose information when rendering an image. Default is false and will
+# only capture and log error messages. When enabled, debug messages are captured and logged as well.
+# For the verbose information to be included in the Grafana server log you have to adjust the rendering log level to debug, configure
+# [log].filter = rendering:debug.
+;rendering_verbose_logging =
+
+# Instruct headless browser instance whether to output its debug and error messages into running process of remote rendering service.
+# Default is false. This can be useful to enable (true) when troubleshooting.
+;rendering_dumpio =
+
+# Additional arguments to pass to the headless browser instance. Default is --no-sandbox. The list of Chromium flags can be found
+# here (https://peter.sh/experiments/chromium-command-line-switches/). Multiple arguments is separated with comma-character.
+;rendering_args =
+
+# You can configure the plugin to use a different browser binary instead of the pre-packaged version of Chromium.
+# Please note that this is not recommended, since you may encounter problems if the installed version of Chrome/Chromium is not
+# compatible with the plugin.
+;rendering_chrome_bin =
+
+# Instruct how headless browser instances are created. Default is 'default' and will create a new browser instance on each request.
+# Mode 'clustered' will make sure that only a maximum of browsers/incognito pages can execute concurrently.
+# Mode 'reusable' will have one browser instance and will create a new incognito page on each request.
+;rendering_mode =
+
+# When rendering_mode = clustered you can instruct how many browsers or incognito pages can execute concurrently. Default is 'browser'
+# and will cluster using browser instances.
+# Mode 'context' will cluster using incognito pages.
+;rendering_clustering_mode =
+# When rendering_mode = clustered you can define maximum number of browser instances/incognito pages that can execute concurrently..
+;rendering_clustering_max_concurrency =
+
+# Limit the maxiumum viewport width, height and device scale factor that can be requested.
+;rendering_viewport_max_width =
+;rendering_viewport_max_height =
+;rendering_viewport_max_device_scale_factor =
+
+# Change the listening host and port of the gRPC server. Default host is 127.0.0.1 and default port is 0 and will automatically assign
+# a port not in use.
+;grpc_host =
+;grpc_port =
+
+[enterprise]
+# Path to a valid Grafana Enterprise license.jwt file
+;license_path =
+
+[feature_toggles]
+# enable features, separated by spaces
+;enable =
diff --git a/compose/grafana_conf/dashboards/apisix_http_prometheus.json b/compose/grafana_conf/dashboards/apisix_http_prometheus.json
new file mode 100644
index 0000000..8dd9f8b
--- /dev/null
+++ b/compose/grafana_conf/dashboards/apisix_http_prometheus.json
@@ -0,0 +1,956 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": "apisix",
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "limit": 100,
+ "name": "Annotations & Alerts",
+ "showIn": 0,
+ "type": "dashboard"
+ }
+ ]
+ },
+ "description": "MicroService API Gateway Apache APISIX",
+ "editable": true,
+ "gnetId": 11719,
+ "graphTooltip": 0,
+ "id": 10,
+ "iteration": 1591947413854,
+ "links": [],
+ "panels": [
+ {
+ "collapsed": false,
+ "datasource": "apisix",
+ "gridPos": {
+ "h": 1,
+ "w": 24,
+ "x": 0,
+ "y": 0
+ },
+ "id": 10,
+ "panels": [],
+ "title": "Nginx",
+ "type": "row"
+ },
+ {
+ "cacheTimeout": null,
+ "colorBackground": false,
+ "colorValue": false,
+ "colors": [
+ "#299c46",
+ "rgba(237, 129, 40, 0.89)",
+ "#d44a3a"
+ ],
+ "datasource": "apisix",
+ "description": "",
+ "fieldConfig": {
+ "defaults": {
+ "custom": {}
+ },
+ "overrides": []
+ },
+ "format": "none",
+ "gauge": {
+ "maxValue": 100,
+ "minValue": 0,
+ "show": false,
+ "thresholdLabels": false,
+ "thresholdMarkers": true
+ },
+ "gridPos": {
+ "h": 5,
+ "w": 5,
+ "x": 0,
+ "y": 1
+ },
+ "id": 8,
+ "interval": null,
+ "links": [],
+ "mappingType": 1,
+ "mappingTypes": [
+ {
+ "name": "value to text",
+ "value": 1
+ },
+ {
+ "name": "range to text",
+ "value": 2
+ }
+ ],
+ "maxDataPoints": 100,
+ "nullPointMode": "connected",
+ "nullText": null,
+ "postfix": "",
+ "postfixFontSize": "50%",
+ "prefix": "",
+ "prefixFontSize": "50%",
+ "rangeMaps": [
+ {
+ "from": "null",
+ "text": "N/A",
+ "to": "null"
+ }
+ ],
+ "sparkline": {
+ "fillColor": "rgba(31, 118, 189, 0.18)",
+ "full": true,
+ "lineColor": "rgb(31, 120, 193)",
+ "show": true,
+ "ymax": null,
+ "ymin": null
+ },
+ "tableColumn": "Total",
+ "targets": [
+ {
+ "expr": "sum(apisix_nginx_http_current_connections{state=\"total\", instance=~\"$instance\"})",
+ "intervalFactor": 2,
+ "legendFormat": "Total",
+ "refId": "A"
+ }
+ ],
+ "thresholds": "",
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Total Connections",
+ "type": "singlestat",
+ "valueFontSize": "80%",
+ "valueMaps": [
+ {
+ "op": "=",
+ "text": "N/A",
+ "value": "null"
+ }
+ ],
+ "valueName": "current"
+ },
+ {
+ "cacheTimeout": null,
+ "colorBackground": false,
+ "colorValue": false,
+ "colors": [
+ "#299c46",
+ "rgba(237, 129, 40, 0.89)",
+ "#d44a3a"
+ ],
+ "datasource": "apisix",
+ "description": "",
+ "fieldConfig": {
+ "defaults": {
+ "custom": {}
+ },
+ "overrides": []
+ },
+ "format": "none",
+ "gauge": {
+ "maxValue": 100,
+ "minValue": 0,
+ "show": false,
+ "thresholdLabels": false,
+ "thresholdMarkers": true
+ },
+ "gridPos": {
+ "h": 5,
+ "w": 5,
+ "x": 5,
+ "y": 1
+ },
+ "id": 16,
+ "interval": null,
+ "links": [],
+ "mappingType": 1,
+ "mappingTypes": [
+ {
+ "name": "value to text",
+ "value": 1
+ },
+ {
+ "name": "range to text",
+ "value": 2
+ }
+ ],
+ "maxDataPoints": 100,
+ "nullPointMode": "connected",
+ "nullText": null,
+ "postfix": "",
+ "postfixFontSize": "50%",
+ "prefix": "",
+ "prefixFontSize": "50%",
+ "rangeMaps": [
+ {
+ "from": "null",
+ "text": "N/A",
+ "to": "null"
+ }
+ ],
+ "sparkline": {
+ "fillColor": "rgba(31, 118, 189, 0.18)",
+ "full": true,
+ "lineColor": "rgb(31, 120, 193)",
+ "show": true,
+ "ymax": null,
+ "ymin": null
+ },
+ "tableColumn": "Accepted",
+ "targets": [
+ {
+ "expr": "sum(apisix_nginx_http_current_connections{state=\"accepted\", instance=~\"$instance\"})",
+ "intervalFactor": 2,
+ "legendFormat": "Accepted",
+ "refId": "A"
+ }
+ ],
+ "thresholds": "",
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Accepted Connections",
+ "type": "singlestat",
+ "valueFontSize": "80%",
+ "valueMaps": [
+ {
+ "op": "=",
+ "text": "N/A",
+ "value": "null"
+ }
+ ],
+ "valueName": "current"
+ },
+ {
+ "cacheTimeout": null,
+ "colorBackground": false,
+ "colorValue": false,
+ "colors": [
+ "#299c46",
+ "rgba(237, 129, 40, 0.89)",
+ "#d44a3a"
+ ],
+ "datasource": "apisix",
+ "description": "",
+ "fieldConfig": {
+ "defaults": {
+ "custom": {}
+ },
+ "overrides": []
+ },
+ "format": "none",
+ "gauge": {
+ "maxValue": 100,
+ "minValue": 0,
+ "show": false,
+ "thresholdLabels": false,
+ "thresholdMarkers": true
+ },
+ "gridPos": {
+ "h": 5,
+ "w": 5,
+ "x": 10,
+ "y": 1
+ },
+ "id": 11,
+ "interval": null,
+ "links": [],
+ "mappingType": 1,
+ "mappingTypes": [
+ {
+ "name": "value to text",
+ "value": 1
+ },
+ {
+ "name": "range to text",
+ "value": 2
+ }
+ ],
+ "maxDataPoints": 100,
+ "nullPointMode": "connected",
+ "nullText": null,
+ "postfix": "",
+ "postfixFontSize": "50%",
+ "prefix": "",
+ "prefixFontSize": "50%",
+ "rangeMaps": [
+ {
+ "from": "null",
+ "text": "N/A",
+ "to": "null"
+ }
+ ],
+ "sparkline": {
+ "fillColor": "rgba(31, 118, 189, 0.18)",
+ "full": true,
+ "lineColor": "rgb(31, 120, 193)",
+ "show": true,
+ "ymax": null,
+ "ymin": null
+ },
+ "tableColumn": "Total",
+ "targets": [
+ {
+ "expr": "sum(apisix_nginx_http_current_connections{state=\"handled\", instance=~\"$instance\"})",
+ "intervalFactor": 2,
+ "legendFormat": "Total",
+ "refId": "A"
+ }
+ ],
+ "thresholds": "",
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Handled Connections",
+ "type": "singlestat",
+ "valueFontSize": "80%",
+ "valueMaps": [
+ {
+ "op": "=",
+ "text": "N/A",
+ "value": "null"
+ }
+ ],
+ "valueName": "current"
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "apisix",
+ "fieldConfig": {
+ "defaults": {
+ "custom": {}
+ },
+ "overrides": []
+ },
+ "fill": 1,
+ "fillGradient": 0,
+ "gridPos": {
+ "h": 6,
+ "w": 15,
+ "x": 0,
+ "y": 6
+ },
+ "hiddenSeries": false,
+ "id": 17,
+ "legend": {
+ "alignAsTable": false,
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "rightSide": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 1,
+ "nullPointMode": "null",
+ "options": {
+ "dataLinks": []
+ },
+ "percentage": false,
+ "pointradius": 2,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "expr": "sum(apisix_nginx_http_current_connections{state=~\"active|reading|writing|waiting\", instance=~\"$instance\"}) by (state)",
+ "intervalFactor": 1,
+ "legendFormat": "{{state}}",
+ "refId": "A"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeRegions": [],
+ "timeShift": null,
+ "title": "Nginx connection state",
+ "tooltip": {
+ "shared": true,
+ "sort": 0,
+ "value_type": "individual"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "collapsed": false,
+ "datasource": "apisix",
+ "gridPos": {
+ "h": 1,
+ "w": 24,
+ "x": 0,
+ "y": 12
+ },
+ "id": 13,
+ "panels": [],
+ "title": "Bandwidth",
+ "type": "row"
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "apisix",
+ "fieldConfig": {
+ "defaults": {
+ "custom": {}
+ },
+ "overrides": []
+ },
+ "fill": 1,
+ "fillGradient": 0,
+ "gridPos": {
+ "h": 6,
+ "w": 15,
+ "x": 0,
+ "y": 13
+ },
+ "hiddenSeries": false,
+ "id": 6,
+ "legend": {
+ "alignAsTable": true,
+ "avg": true,
+ "current": true,
+ "max": true,
+ "min": true,
+ "rightSide": true,
+ "show": true,
+ "total": false,
+ "values": true
+ },
+ "lines": true,
+ "linewidth": 1,
+ "nullPointMode": "null",
+ "options": {
+ "dataLinks": []
+ },
+ "percentage": false,
+ "pointradius": 2,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "expr": "sum(rate(apisix_bandwidth{instance=~\"$instance\"}[30s])) by (type)",
+ "legendFormat": "{{type}}",
+ "refId": "A"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeRegions": [],
+ "timeShift": null,
+ "title": "Total Bandwidth",
+ "tooltip": {
+ "shared": true,
+ "sort": 0,
+ "value_type": "individual"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "apisix",
+ "fieldConfig": {
+ "defaults": {
+ "custom": {}
+ },
+ "overrides": []
+ },
+ "fill": 1,
+ "fillGradient": 0,
+ "gridPos": {
+ "h": 6,
+ "w": 7,
+ "x": 0,
+ "y": 19
+ },
+ "hiddenSeries": false,
+ "id": 19,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 1,
+ "nullPointMode": "null",
+ "options": {
+ "dataLinks": []
+ },
+ "percentage": false,
+ "pointradius": 2,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "expr": "sum(rate(apisix_bandwidth{type=\"egress\", service =~\"$service\",route=~\"$route\",instance=~\"$instance\"}[1m])) by (service)",
+ "legendFormat": "service:{{service}}",
+ "refId": "A"
+ },
+ {
+ "expr": "sum(rate(apisix_bandwidth{type=\"egress\", service =~\"$service\",route=~\"$route\",instance=~\"$instance\"}[1m])) by (route)",
+ "legendFormat": "route:{{route}}",
+ "refId": "B"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeRegions": [],
+ "timeShift": null,
+ "title": "Egress per service/route",
+ "tooltip": {
+ "shared": true,
+ "sort": 0,
+ "value_type": "individual"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "apisix",
+ "fieldConfig": {
+ "defaults": {
+ "custom": {}
+ },
+ "overrides": []
+ },
+ "fill": 1,
+ "fillGradient": 0,
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 7,
+ "y": 19
+ },
+ "hiddenSeries": false,
+ "id": 21,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 1,
+ "nullPointMode": "null",
+ "options": {
+ "dataLinks": []
+ },
+ "percentage": false,
+ "pointradius": 2,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "expr": "sum(irate(apisix_bandwidth{type=\"ingress\", service =~\"$service\",route=~\"$route\",instance=~\"$instance\"}[1m])) by (service)",
+ "legendFormat": "service:{{service}}",
+ "refId": "A"
+ },
+ {
+ "expr": "sum(irate(apisix_bandwidth{type=\"ingress\", service =~\"$service\",route=~\"$route\",instance=~\"$instance\"}[1m])) by (route)",
+ "legendFormat": "route:{{route}}",
+ "refId": "B"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeRegions": [],
+ "timeShift": null,
+ "title": "Ingress per service/route",
+ "tooltip": {
+ "shared": true,
+ "sort": 0,
+ "value_type": "individual"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "collapsed": false,
+ "datasource": "apisix",
+ "gridPos": {
+ "h": 1,
+ "w": 24,
+ "x": 0,
+ "y": 25
+ },
+ "id": 15,
+ "panels": [],
+ "title": "HTTP Status",
+ "type": "row"
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "cacheTimeout": null,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "apisix",
+ "fieldConfig": {
+ "defaults": {
+ "custom": {}
+ },
+ "overrides": []
+ },
+ "fill": 3,
+ "fillGradient": 0,
+ "gridPos": {
+ "h": 6,
+ "w": 15,
+ "x": 0,
+ "y": 26
+ },
+ "hiddenSeries": false,
+ "id": 2,
+ "interval": "",
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 1,
+ "links": [],
+ "nullPointMode": "null",
+ "options": {
+ "dataLinks": []
+ },
+ "percentage": false,
+ "pluginVersion": "6.5.2",
+ "pointradius": 2,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [
+ {
+ "alias": "state",
+ "lines": true
+ }
+ ],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "expr": "sum(rate(apisix_http_status{code=~\"2..\",service=~\"$service\",route=~\"$route\",instance=~\"$instance\"}[30s])) by (status_2xx)",
+ "instant": false,
+ "intervalFactor": 1,
+ "legendFormat": "{{status_2xx}}",
+ "refId": "A"
+ },
+ {
+ "expr": "sum(rate(apisix_http_status{code=~\"3..\",service=~\"$service\",route=~\"$route\",instance=~\"$instance\"}[30s])) by (status_3xx)",
+ "legendFormat": "{{status_3xx}}",
+ "refId": "D"
+ },
+ {
+ "expr": "sum(rate(apisix_http_status{code=~\"4..\",service=~\"$service\",route=~\"$route\",instance=~\"$instance\"}[30s])) by (status_4xx)",
+ "intervalFactor": 1,
+ "legendFormat": "{{status_4xx}}",
+ "refId": "B"
+ },
+ {
+ "expr": "sum(rate(apisix_http_status{code=~\"5..\",service=~\"$service\",route=~\"$route\",instance=~\"$instance\"}[30s])) by (status_5xx)",
+ "legendFormat": "{{status_5xx}}",
+ "refId": "C"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeRegions": [],
+ "timeShift": null,
+ "title": "Service HTTP Code",
+ "tooltip": {
+ "shared": true,
+ "sort": 0,
+ "value_type": "individual"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ }
+ ],
+ "refresh": "5s",
+ "schemaVersion": 25,
+ "style": "dark",
+ "tags": [],
+ "templating": {
+ "list": [
+ {
+ "allValue": ".*",
+ "current": {
+ "selected": false,
+ "text": "All",
+ "value": "$__all"
+ },
+ "datasource": "apisix",
+ "definition": "label_values(apisix_http_status,service)",
+ "hide": 0,
+ "includeAll": true,
+ "label": null,
+ "multi": true,
+ "name": "service",
+ "options": [],
+ "query": "label_values(apisix_http_status,service)",
+ "refresh": 1,
+ "regex": "",
+ "skipUrlSync": false,
+ "sort": 1,
+ "tagValuesQuery": "",
+ "tags": [],
+ "tagsQuery": "",
+ "type": "query",
+ "useTags": false
+ },
+ {
+ "allValue": ".*",
+ "current": {
+ "selected": false,
+ "text": "All",
+ "value": "$__all"
+ },
+ "datasource": "apisix",
+ "definition": "label_values(apisix_http_status,route)",
+ "hide": 0,
+ "includeAll": true,
+ "label": null,
+ "multi": true,
+ "name": "route",
+ "options": [],
+ "query": "label_values(apisix_http_status,route)",
+ "refresh": 1,
+ "regex": "",
+ "skipUrlSync": false,
+ "sort": 1,
+ "tagValuesQuery": "",
+ "tags": [],
+ "tagsQuery": "",
+ "type": "query",
+ "useTags": false
+ },
+ {
+ "allValue": ".*",
+ "current": {
+ "selected": false,
+ "text": "All",
+ "value": "$__all"
+ },
+ "datasource": "apisix",
+ "definition": "label_values(apisix_http_status,instance)",
+ "hide": 0,
+ "includeAll": true,
+ "label": null,
+ "multi": true,
+ "name": "instance",
+ "options": [],
+ "query": "label_values(apisix_http_status,instance)",
+ "refresh": 2,
+ "regex": ".*",
+ "skipUrlSync": false,
+ "sort": 1,
+ "tagValuesQuery": "",
+ "tags": [],
+ "tagsQuery": "",
+ "type": "query",
+ "useTags": false
+ }
+ ]
+ },
+ "time": {
+ "from": "now-30m",
+ "to": "now"
+ },
+ "timepicker": {
+ "refresh_intervals": [
+ "5s",
+ "10s",
+ "30s",
+ "1m",
+ "5m",
+ "15m",
+ "30m",
+ "1h",
+ "2h",
+ "1d"
+ ]
+ },
+ "timezone": "",
+ "title": "Apache APISIX",
+ "uid": "bLlNuRLWz",
+ "version": 1
+}
\ No newline at end of file
diff --git a/compose/grafana_conf/provisioning/dashboards/all.yaml b/compose/grafana_conf/provisioning/dashboards/all.yaml
new file mode 100644
index 0000000..c58cbc6
--- /dev/null
+++ b/compose/grafana_conf/provisioning/dashboards/all.yaml
@@ -0,0 +1,11 @@
+apiVersion: 1
+
+providers:
+- name: 'default'
+ orgId: 1
+ folder: ''
+ type: file
+ disableDeletion: false
+ editable: false
+ options:
+ path: /var/lib/grafana/dashboards
\ No newline at end of file
diff --git a/compose/grafana_conf/provisioning/datasources/all.yaml b/compose/grafana_conf/provisioning/datasources/all.yaml
new file mode 100644
index 0000000..4245eac
--- /dev/null
+++ b/compose/grafana_conf/provisioning/datasources/all.yaml
@@ -0,0 +1,9 @@
+datasources:
+ - access: 'proxy'
+ editable: true
+ is_default: true
+ name: 'apisix'
+ org_id: 1
+ type: 'prometheus'
+ url: 'http://prometheus:9090'
+ version: 1
\ No newline at end of file
diff --git a/compose/manager_conf/build.sh b/compose/manager_conf/build.sh
new file mode 100755
index 0000000..efedb1c
--- /dev/null
+++ b/compose/manager_conf/build.sh
@@ -0,0 +1,20 @@
+#!/bin/sh
+
+pwd=`pwd`
+
+export MYSQL_SERVER_ADDRESS="192.17.5.14:3306"
+export MYSQL_USER=root
+export MYSQL_PASSWORD=123456
+export SYSLOG_HOST=127.0.0.1
+export APISIX_BASE_URL="http://192.17.5.11:9080/apisix/admin"
+export APISIX_API_KEY="edd1c9f034335f136f87ad84b625c8f1"
+
+sed -i -e "s%#mysqlAddress#%`echo $MYSQL_SERVER_ADDRESS`%g" ${pwd}/conf.json
+sed -i -e "s%#mysqlUser#%`echo $MYSQL_USER`%g" ${pwd}/conf.json
+sed -i -e "s%#mysqlPWD#%`echo $MYSQL_PASSWORD`%g" ${pwd}/conf.json
+sed -i -e "s%#syslogAddress#%`echo $SYSLOG_HOST`%g" ${pwd}/conf.json
+sed -i -e "s%#apisixBaseUrl#%`echo $APISIX_BASE_URL`%g" ${pwd}/conf.json
+sed -i -e "s%#apisixApiKey#%`echo $APISIX_API_KEY`%g" ${pwd}/conf.json
+
+cd /root/manager-api
+exec ./manager-api
diff --git a/compose/pics/grafana_1.png b/compose/pics/grafana_1.png
new file mode 100644
index 0000000..631276e
Binary files /dev/null and b/compose/pics/grafana_1.png differ
diff --git a/compose/pics/grafana_2.png b/compose/pics/grafana_2.png
new file mode 100644
index 0000000..03711eb
Binary files /dev/null and b/compose/pics/grafana_2.png differ
diff --git a/compose/pics/grafana_3.png b/compose/pics/grafana_3.png
new file mode 100644
index 0000000..5b2e834
Binary files /dev/null and b/compose/pics/grafana_3.png differ
diff --git a/compose/pics/grafana_4.png b/compose/pics/grafana_4.png
new file mode 100644
index 0000000..237e4dc
Binary files /dev/null and b/compose/pics/grafana_4.png differ
diff --git a/compose/pics/grafana_5.png b/compose/pics/grafana_5.png
new file mode 100644
index 0000000..dd9654d
Binary files /dev/null and b/compose/pics/grafana_5.png differ
diff --git a/compose/pics/grafana_6.png b/compose/pics/grafana_6.png
new file mode 100644
index 0000000..8931a0d
Binary files /dev/null and b/compose/pics/grafana_6.png differ
diff --git a/compose/pics/login.png b/compose/pics/login.png
new file mode 100644
index 0000000..c42e375
Binary files /dev/null and b/compose/pics/login.png differ
diff --git a/compose/prometheus_conf/prometheus.yml b/compose/prometheus_conf/prometheus.yml
new file mode 100644
index 0000000..0804c81
--- /dev/null
+++ b/compose/prometheus_conf/prometheus.yml
@@ -0,0 +1,23 @@
+global:
+ scrape_interval: 15s # By default, scrape targets every 15 seconds.
+
+ # Attach these labels to any time series or alerts when communicating with
+ # external systems (federation, remote storage, Alertmanager).
+ external_labels:
+ monitor: "codelab-monitor"
+
+# A scrape configuration containing exactly one endpoint to scrape:
+# Here it's Prometheus itself.
+scrape_configs:
+ # The job name is added as a label `job=<job_name>` to any timeseries scraped from this config.
+ - job_name: "prometheus"
+
+ # Override the global default and scrape targets from this job every 5 seconds.
+ scrape_interval: 5s
+
+ static_configs:
+ - targets: ["localhost:9090"]
+ - job_name: "apisix"
+ metrics_path: "/apisix/prometheus/metrics"
+ static_configs:
+ - targets: ["192.17.5.11:9080"]
diff --git a/config/config.ts b/config/config.ts
new file mode 100644
index 0000000..b332ab4
--- /dev/null
+++ b/config/config.ts
@@ -0,0 +1,48 @@
+import { defineConfig } from 'umi';
+
+import defaultSettings from './defaultSettings';
+import proxy from './proxy';
+import routes from './routes';
+
+const { REACT_APP_ENV } = process.env;
+
+export default defineConfig({
+ hash: true,
+ antd: {},
+ dva: {
+ hmr: true,
+ },
+ locale: {
+ default: 'zh-CN',
+ antd: true,
+ baseNavigator: true,
+ },
+ dynamicImport: {
+ loading: '@/components/PageLoading/index',
+ },
+ targets: {
+ ie: 11,
+ },
+ routes,
+ layout: {
+ name: 'APISIX Dashboard',
+ locale: true,
+ logo: '/favicon.png',
+ },
+ base: '/dashboard/',
+ publicPath: '/',
+ define: {
+ REACT_APP_ENV: REACT_APP_ENV || false,
+ },
+ // Theme for antd: https://ant.design/docs/react/customize-theme-cn
+ theme: {
+ 'primary-color': defaultSettings.primaryColor,
+ },
+ // @ts-ignore
+ title: false,
+ ignoreMomentLocale: true,
+ proxy: proxy[REACT_APP_ENV || 'dev'],
+ manifest: {
+ basePath: '/',
+ },
+});
diff --git a/config/defaultSettings.ts b/config/defaultSettings.ts
new file mode 100644
index 0000000..0245a76
--- /dev/null
+++ b/config/defaultSettings.ts
@@ -0,0 +1,20 @@
+import { Settings as LayoutSettings } from '@ant-design/pro-layout';
+
+export default {
+ navTheme: 'light',
+ primaryColor: '#1890ff',
+ layout: 'mix',
+ contentWidth: 'Fluid',
+ fixedHeader: false,
+ autoHideHeader: false,
+ fixSiderbar: false,
+ colorWeak: false,
+ menu: {
+ locale: true,
+ },
+ title: 'APISIX Dashboard',
+ pwa: false,
+ iconfontUrl: '',
+} as LayoutSettings & {
+ pwa: boolean;
+};
diff --git a/config/proxy.ts b/config/proxy.ts
new file mode 100644
index 0000000..e189b0f
--- /dev/null
+++ b/config/proxy.ts
@@ -0,0 +1,30 @@
+/**
+ * 在生产环境 代理是无法生效的,所以这里没有生产环境的配置
+ * The agent cannot take effect in the production environment
+ * so there is no configuration of the production environment
+ * For details, please see
+ * https://pro.ant.design/docs/deploy
+ */
+export default {
+ dev: {
+ '/api/': {
+ target: 'https://apisix.iresty.com/apisix/admin/',
+ changeOrigin: true,
+ pathRewrite: { '^/api': '' },
+ },
+ },
+ test: {
+ '/api/': {
+ target: 'https://preview.pro.ant.design',
+ changeOrigin: true,
+ pathRewrite: { '^': '' },
+ },
+ },
+ pre: {
+ '/api/': {
+ target: 'your pre url',
+ changeOrigin: true,
+ pathRewrite: { '^': '' },
+ },
+ },
+};
diff --git a/config/routes.ts b/config/routes.ts
new file mode 100644
index 0000000..75bee66
--- /dev/null
+++ b/config/routes.ts
@@ -0,0 +1,76 @@
+const routes = [
+ {
+ path: '/',
+ redirect: '/ssl',
+ },
+ {
+ name: 'metrics',
+ path: '/metrics',
+ component: './Metrics/Metrics',
+ icon: 'AreaChartOutlined',
+ },
+ {
+ name: 'setting',
+ path: '/setting',
+ component: './Setting',
+ layout: false,
+ hideInMenu: true,
+ },
+ {
+ name: 'ssl',
+ path: '/ssl',
+ icon: 'BarsOutlined',
+ routes: [
+ {
+ path: '/ssl',
+ redirect: '/ssl/list',
+ },
+ {
+ path: '/ssl/list',
+ name: 'list',
+ component: './ssl/List',
+ hideInMenu: true,
+ },
+ {
+ name: 'create',
+ path: '/ssl/create',
+ component: './ssl/Create',
+ hideInMenu: true,
+ },
+ ],
+ },
+ {
+ name: 'routes',
+ path: '/routes',
+ icon: 'BarsOutlined',
+ routes: [
+ {
+ path: '/routes',
+ redirect: '/routes/list',
+ },
+ {
+ path: '/routes/list',
+ name: 'list',
+ icon: 'BarsOutlined',
+ component: './Routes/List',
+ },
+ {
+ path: '/routes/create',
+ name: 'create',
+ component: './Routes/Create',
+ hideInMenu: true,
+ },
+ {
+ path: '/routes/:rid/edit',
+ name: 'edit',
+ component: './Routes/Create',
+ hideInMenu: true,
+ },
+ ],
+ },
+ {
+ component: './404',
+ },
+];
+
+export default routes;
diff --git a/docker/nginx.conf b/docker/nginx.conf
new file mode 100644
index 0000000..40c19cb
--- /dev/null
+++ b/docker/nginx.conf
@@ -0,0 +1,21 @@
+server {
+ listen 80;
+ # gzip config
+ gzip on;
+ gzip_min_length 1k;
+ gzip_comp_level 9;
+ gzip_types text/plain text/css text/javascript application/json application/javascript application/x-javascript application/xml;
+ gzip_vary on;
+ gzip_disable "MSIE [1-6]\.";
+
+ root /usr/share/nginx/html;
+ include /etc/nginx/mime.types;
+
+ location / {
+ rewrite ^/ /dashboard$uri redirect;
+ }
+
+ location /dashboard {
+ try_files $uri $uri/ /index.html;
+ }
+}
diff --git a/jest.config.js b/jest.config.js
new file mode 100644
index 0000000..4c4eeaf
--- /dev/null
+++ b/jest.config.js
@@ -0,0 +1,9 @@
+module.exports = {
+ testURL: 'http://localhost:8000',
+ testEnvironment: './tests/PuppeteerEnvironment',
+ verbose: false,
+ globals: {
+ ANT_DESIGN_PRO_ONLY_DO_NOT_USE_IN_YOUR_PRODUCTION: false,
+ localStorage: null,
+ },
+};
diff --git a/jsconfig.json b/jsconfig.json
new file mode 100644
index 0000000..f87334d
--- /dev/null
+++ b/jsconfig.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "emitDecoratorMetadata": true,
+ "experimentalDecorators": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ }
+}
diff --git a/mock/notices.ts b/mock/notices.ts
new file mode 100644
index 0000000..b9e3bf2
--- /dev/null
+++ b/mock/notices.ts
@@ -0,0 +1,105 @@
+import { Request, Response } from 'express';
+
+const getNotices = (req: Request, res: Response) => {
+ res.json([
+ {
+ id: '000000001',
+ avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png',
+ title: '你收到了 14 份新周报',
+ datetime: '2017-08-09',
+ type: 'notification',
+ },
+ {
+ id: '000000002',
+ avatar: 'https://gw.alipayobjects.com/zos/rmsportal/OKJXDXrmkNshAMvwtvhu.png',
+ title: '你推荐的 曲妮妮 已通过第三轮面试',
+ datetime: '2017-08-08',
+ type: 'notification',
+ },
+ {
+ id: '000000003',
+ avatar: 'https://gw.alipayobjects.com/zos/rmsportal/kISTdvpyTAhtGxpovNWd.png',
+ title: '这种模板可以区分多种通知类型',
+ datetime: '2017-08-07',
+ read: true,
+ type: 'notification',
+ },
+ {
+ id: '000000004',
+ avatar: 'https://gw.alipayobjects.com/zos/rmsportal/GvqBnKhFgObvnSGkDsje.png',
+ title: '左侧图标用于区分不同的类型',
+ datetime: '2017-08-07',
+ type: 'notification',
+ },
+ {
+ id: '000000005',
+ avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png',
+ title: '内容不要超过两行字,超出时自动截断',
+ datetime: '2017-08-07',
+ type: 'notification',
+ },
+ {
+ id: '000000006',
+ avatar: 'https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg',
+ title: '曲丽丽 评论了你',
+ description: '描述信息描述信息描述信息',
+ datetime: '2017-08-07',
+ type: 'message',
+ clickClose: true,
+ },
+ {
+ id: '000000007',
+ avatar: 'https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg',
+ title: '朱偏右 回复了你',
+ description: '这种模板用于提醒谁与你发生了互动,左侧放『谁』的头像',
+ datetime: '2017-08-07',
+ type: 'message',
+ clickClose: true,
+ },
+ {
+ id: '000000008',
+ avatar: 'https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg',
+ title: '标题',
+ description: '这种模板用于提醒谁与你发生了互动,左侧放『谁』的头像',
+ datetime: '2017-08-07',
+ type: 'message',
+ clickClose: true,
+ },
+ {
+ id: '000000009',
+ title: '任务名称',
+ description: '任务需要在 2017-01-12 20:00 前启动',
+ extra: '未开始',
+ status: 'todo',
+ type: 'event',
+ },
+ {
+ id: '000000010',
+ title: '第三方紧急代码变更',
+ description: '冠霖提交于 2017-01-06,需在 2017-01-07 前完成代码变更任务',
+ extra: '马上到期',
+ status: 'urgent',
+ type: 'event',
+ },
+ {
+ id: '000000011',
+ title: '信息安全考试',
+ description: '指派竹尔于 2017-01-09 前完成更新并发布',
+ extra: '已耗时 8 天',
+ status: 'doing',
+ type: 'event',
+ },
+ {
+ id: '000000012',
+ title: 'ABCD 版本发布',
+ description: '冠霖提交于 2017-01-06,需在 2017-01-07 前完成代码变更任务',
+ extra: '进行中',
+ status: 'processing',
+ type: 'event',
+ },
+ ]);
+};
+
+export default {
+ 'GET /api/notices': getNotices,
+};
diff --git a/mock/route.ts b/mock/route.ts
new file mode 100644
index 0000000..418d10f
--- /dev/null
+++ b/mock/route.ts
@@ -0,0 +1,5 @@
+export default {
+ '/api/auth_routes': {
+ '/form/advanced-form': { authority: ['admin', 'user'] },
+ },
+};
diff --git a/mock/user.ts b/mock/user.ts
new file mode 100644
index 0000000..24fa3f7
--- /dev/null
+++ b/mock/user.ts
@@ -0,0 +1,154 @@
+import { Request, Response } from 'express';
+
+function getFakeCaptcha(req: Request, res: Response) {
+ return res.json('captcha-xxx');
+}
+// 代码中会兼容本地 service mock 以及部署站点的静态数据
+export default {
+ // 支持值为 Object 和 Array
+ 'GET /api/currentUser': {
+ name: 'Serati Ma',
+ avatar: 'https://gw.alipayobjects.com/zos/antfincdn/XAosXuNZyF/BiazfanxmamNRoxxVxka.png',
+ userid: '00000001',
+ email: 'antdesign@alipay.com',
+ signature: '海纳百川,有容乃大',
+ title: '交互专家',
+ group: '蚂蚁金服-某某某事业群-某某平台部-某某技术部-UED',
+ tags: [
+ {
+ key: '0',
+ label: '很有想法的',
+ },
+ {
+ key: '1',
+ label: '专注设计',
+ },
+ {
+ key: '2',
+ label: '辣~',
+ },
+ {
+ key: '3',
+ label: '大长腿',
+ },
+ {
+ key: '4',
+ label: '川妹子',
+ },
+ {
+ key: '5',
+ label: '海纳百川',
+ },
+ ],
+ notifyCount: 12,
+ unreadCount: 11,
+ country: 'China',
+ geographic: {
+ province: {
+ label: '浙江省',
+ key: '330000',
+ },
+ city: {
+ label: '杭州市',
+ key: '330100',
+ },
+ },
+ address: '西湖区工专路 77 号',
+ phone: '0752-268888888',
+ },
+ // GET POST 可省略
+ 'GET /api/users': [
+ {
+ key: '1',
+ name: 'John Brown',
+ age: 32,
+ address: 'New York No. 1 Lake Park',
+ },
+ {
+ key: '2',
+ name: 'Jim Green',
+ age: 42,
+ address: 'London No. 1 Lake Park',
+ },
+ {
+ key: '3',
+ name: 'Joe Black',
+ age: 32,
+ address: 'Sidney No. 1 Lake Park',
+ },
+ ],
+ 'POST /api/login/account': (req: Request, res: Response) => {
+ const { password, userName, type } = req.body;
+ if (password === 'ant.design' && userName === 'admin') {
+ res.send({
+ status: 'ok',
+ type,
+ currentAuthority: 'admin',
+ });
+ return;
+ }
+ if (password === 'ant.design' && userName === 'user') {
+ res.send({
+ status: 'ok',
+ type,
+ currentAuthority: 'user',
+ });
+ return;
+ }
+ if (type === 'mobile') {
+ res.send({
+ status: 'ok',
+ type,
+ currentAuthority: 'admin',
+ });
+ return;
+ }
+
+ res.send({
+ status: 'error',
+ type,
+ currentAuthority: 'guest',
+ });
+ },
+ 'POST /api/register': (req: Request, res: Response) => {
+ res.send({ status: 'ok', currentAuthority: 'user' });
+ },
+ 'GET /api/500': (req: Request, res: Response) => {
+ res.status(500).send({
+ timestamp: 1513932555104,
+ status: 500,
+ error: 'error',
+ message: 'error',
+ path: '/base/category/list',
+ });
+ },
+ 'GET /api/404': (req: Request, res: Response) => {
+ res.status(404).send({
+ timestamp: 1513932643431,
+ status: 404,
+ error: 'Not Found',
+ message: 'No message available',
+ path: '/base/category/list/2121212',
+ });
+ },
+ 'GET /api/403': (req: Request, res: Response) => {
+ res.status(403).send({
+ timestamp: 1513932555104,
+ status: 403,
+ error: 'Unauthorized',
+ message: 'Unauthorized',
+ path: '/base/category/list',
+ });
+ },
+ 'GET /api/401': (req: Request, res: Response) => {
+ res.status(401).send({
+ timestamp: 1513932555104,
+ status: 401,
+ error: 'Unauthorized',
+ message: 'Unauthorized',
+ path: '/base/category/list',
+ });
+ },
+
+ 'GET /api/login/captcha': getFakeCaptcha,
+};
diff --git a/netlify.toml b/netlify.toml
new file mode 100644
index 0000000..89a3866
--- /dev/null
+++ b/netlify.toml
@@ -0,0 +1,13 @@
+[build]
+ publish = "dist/"
+
+[[redirects]]
+ from = "/api/*"
+ to = "https://apisix.iresty.com/apisix/admin/:splat"
+ status = 200
+ force = true
+
+[[redirects]]
+ from = "/*"
+ to = "/index.html"
+ status = 200
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..2dd0b40
--- /dev/null
+++ b/package.json
@@ -0,0 +1,109 @@
+{
+ "name": "apisix-dashboard",
+ "version": "1.0.2",
+ "private": true,
+ "description": "Dashboard for APISIX",
+ "scripts": {
+ "analyze": "cross-env ANALYZE=1 umi build",
+ "build": "umi build",
+ "dev": "npm run start:dev",
+ "fetch:blocks": "pro fetch-blocks --branch antd@4 && npm run prettier",
+ "i18n-remove": "pro i18n-remove --locale=zh-CN --write",
+ "postinstall": "umi g tmp",
+ "lint": "umi g tmp && npm run lint:js && npm run lint:style && npm run lint:prettier",
+ "lint-staged": "lint-staged",
+ "lint-staged:js": "eslint --ext .js,.jsx,.ts,.tsx ",
+ "lint:fix": "eslint --fix --cache --ext .js,.jsx,.ts,.tsx --format=pretty ./src && npm run lint:style",
+ "lint:js": "eslint --cache --ext .js,.jsx,.ts,.tsx --format=pretty ./src",
+ "lint:prettier": "prettier --check \"**/*\" --end-of-line auto",
+ "lint:style": "stylelint --fix \"src/**/*.less\" --syntax less",
+ "prettier": "prettier -c --write \"**/*\"",
+ "site": "npm run fetch:blocks && npm run build",
+ "start": "umi dev",
+ "start:dev": "cross-env REACT_APP_ENV=dev MOCK=none umi dev",
+ "start:no-mock": "cross-env MOCK=none umi dev",
+ "start:no-ui": "cross-env UMI_UI=none umi dev",
+ "start:pre": "cross-env REACT_APP_ENV=pre umi dev",
+ "start:test": "cross-env REACT_APP_ENV=test MOCK=none umi dev",
+ "pretest": "node ./tests/beforeTest",
+ "test": "umi test",
+ "test:all": "node ./tests/run-tests.js",
+ "test:component": "umi test ./src/components",
+ "tsc": "tsc"
+ },
+ "husky": {
+ "hooks": {
+ "pre-commit": "npm run lint-staged"
+ }
+ },
+ "lint-staged": {
+ "**/*.less": "stylelint --syntax less",
+ "**/*.{js,jsx,ts,tsx}": "npm run lint-staged:js",
+ "**/*.{js,jsx,tsx,ts,less,md,json}": [
+ "prettier --write"
+ ]
+ },
+ "dependencies": {
+ "@ant-design/icons": "^4.2.1",
+ "@ant-design/pro-layout": "6.0.0-2",
+ "@ant-design/pro-table": "^2.3.3",
+ "antd": "^4.3.3",
+ "classnames": "^2.2.6",
+ "lodash": "^4.17.15",
+ "moment": "^2.25.3",
+ "nzh": "^1.0.3",
+ "omit.js": "^1.0.2",
+ "path-to-regexp": "2.4.0",
+ "qs": "^6.9.0",
+ "react": "^16.8.6",
+ "react-dom": "^16.8.6",
+ "umi": "^3.1.0",
+ "umi-request": "^1.3.3",
+ "use-merge-value": "^1.0.1",
+ "uuid": "^7.0.2"
+ },
+ "devDependencies": {
+ "@ant-design/pro-cli": "^2.0.2",
+ "@types/classnames": "^2.2.7",
+ "@types/express": "^4.17.0",
+ "@types/history": "^4.7.2",
+ "@types/jest": "^25.1.0",
+ "@types/lodash": "^4.14.144",
+ "@types/node-forge": "^0.9.3",
+ "@types/qs": "^6.5.3",
+ "@types/react": "^16.9.17",
+ "@types/react-dom": "^16.8.4",
+ "@types/react-helmet": "^5.0.13",
+ "@types/uuid": "^7.0.0",
+ "@umijs/fabric": "^2.0.5",
+ "@umijs/plugin-blocks": "^2.0.5",
+ "@umijs/plugin-esbuild": "^1.0.0-beta.2",
+ "@umijs/preset-ant-design-pro": "^1.2.0",
+ "@umijs/preset-react": "^1.4.24",
+ "@umijs/preset-ui": "^2.1.11",
+ "carlo": "^0.9.46",
+ "cross-env": "^7.0.0",
+ "cross-port-killer": "^1.1.1",
+ "detect-installer": "^1.0.1",
+ "eslint": "^6.8.0",
+ "express": "^4.17.1",
+ "husky": "^4.0.7",
+ "lint-staged": "^10.0.0",
+ "mockjs": "^1.0.1-beta3",
+ "prettier": "^2.0.1",
+ "pro-download": "1.0.1",
+ "puppeteer-core": "^2.1.1",
+ "react-helmet-async": "^1.0.6",
+ "stylelint": "^13.0.0"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "checkFiles": [
+ "src/**/*.js*",
+ "src/**/*.ts*",
+ "src/**/*.less",
+ "config/**/*.js*",
+ "scripts/**/*.js"
+ ]
+}
diff --git a/public/favicon.png b/public/favicon.png
new file mode 100644
index 0000000..381ab08
Binary files /dev/null and b/public/favicon.png differ
diff --git a/public/home_bg.png b/public/home_bg.png
new file mode 100644
index 0000000..7c92a4b
Binary files /dev/null and b/public/home_bg.png differ
diff --git a/public/icons/icon-128x128.png b/public/icons/icon-128x128.png
new file mode 100644
index 0000000..48d0e23
Binary files /dev/null and b/public/icons/icon-128x128.png differ
diff --git a/public/icons/icon-192x192.png b/public/icons/icon-192x192.png
new file mode 100644
index 0000000..938e9b5
Binary files /dev/null and b/public/icons/icon-192x192.png differ
diff --git a/public/icons/icon-512x512.png b/public/icons/icon-512x512.png
new file mode 100644
index 0000000..21fc108
Binary files /dev/null and b/public/icons/icon-512x512.png differ
diff --git a/public/pro_icon.svg b/public/pro_icon.svg
new file mode 100644
index 0000000..2c24ec7
--- /dev/null
+++ b/public/pro_icon.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="图层_1" width="512" height="512" x="0" y="0" enable-background="new 0 0 512 512" version="1.1" viewBox="0 0 512 512" xml:space="preserve"><path fill-rule="evenodd" d="M259.119,233.588c0-3.644,0.041-7.289-0.008-10.932 c-0.111-8.558-4.697-13.308-13.231-13.486c-6.658-0.139-13.326,0.12-19.98-0.096c-3.292-0.107-4.247,0.995-4.24,4.266 c0.094,44.794,0.101,89.589-0.008,134.383c-0.009,3.492,1.346,4.154,4.407,4.11 [...]
\ No newline at end of file
diff --git a/src/access.ts b/src/access.ts
new file mode 100644
index 0000000..207bc4b
--- /dev/null
+++ b/src/access.ts
@@ -0,0 +1,6 @@
+export default function (initialState: { currentUser?: API.CurrentUser | undefined }) {
+ const { currentUser } = initialState || {};
+ return {
+ canAdmin: currentUser && currentUser.access === 'admin',
+ };
+}
diff --git a/src/app.tsx b/src/app.tsx
new file mode 100644
index 0000000..1a38f9c
--- /dev/null
+++ b/src/app.tsx
@@ -0,0 +1,92 @@
+import React from 'react';
+import { notification } from 'antd';
+import { RequestConfig, history } from 'umi';
+import { BasicLayoutProps, Settings as LayoutSettings } from '@ant-design/pro-layout';
+
+import { getSetting } from '@/pages/Setting';
+import RightContent from '@/components/RightContent';
+import Footer from '@/components/Footer';
+import { queryCurrent } from '@/services/user';
+import defaultSettings from '../config/defaultSettings';
+
+export async function getInitialState(): Promise<{
+ currentUser?: API.CurrentUser;
+ settings?: LayoutSettings;
+}> {
+ // 如果是设置页面,不执行
+ if (history.location.pathname !== '/setting') {
+ try {
+ const currentUser = await queryCurrent();
+ return {
+ currentUser,
+ settings: defaultSettings,
+ };
+ } catch (error) {
+ history.push('/setting');
+ }
+ }
+ return {
+ settings: defaultSettings,
+ };
+}
+
+export const layout = ({
+ initialState,
+}: {
+ initialState: { settings?: LayoutSettings };
+}): BasicLayoutProps => {
+ return {
+ rightContentRender: () => <RightContent />,
+ disableContentMargin: false,
+ footerRender: () => <Footer />,
+ menuHeaderRender: undefined,
+ ...initialState?.settings,
+ };
+};
+
+const codeMessage = {
+ 200: '服务器成功返回请求的数据。',
+ 201: '新建或修改数据成功。',
+ 202: '一个请求已经进入后台排队(异步任务)。',
+ 204: '删除数据成功。',
+ 400: '发出的请求有错误,服务器没有进行新建或修改数据的操作。',
+ 401: '用户没有权限(令牌、用户名、密码错误)。',
+ 403: '用户得到授权,但是访问是被禁止的。',
+ 404: '发出的请求针对的是不存在的记录,服务器没有进行操作。',
+ 406: '请求的格式不可得。',
+ 410: '请求的资源被永久删除,且不会再得到的。',
+ 422: '当创建一个对象时,发生一个验证错误。',
+ 500: '服务器发生错误,请检查服务器。',
+ 502: '网关错误。',
+ 503: '服务不可用,服务器暂时过载或维护。',
+ 504: '网关超时。',
+};
+
+/**
+ * 异常处理程序
+ */
+const errorHandler = (error: { response: Response; data: any }): Promise<Response> => {
+ const { response } = error;
+ if (response && response.status) {
+ const errorText =
+ error.data.msg || error.data.message || error.data.error_msg || codeMessage[response.status];
+
+ notification.error({
+ message: `请求错误,错误码: ${error.data.errorCode || response.status}`,
+ description: errorText,
+ });
+ } else if (!response) {
+ notification.error({
+ description: '您的网络发生异常,无法连接服务器',
+ message: '网络异常',
+ });
+ }
+ return Promise.reject(response);
+};
+
+const { baseURL } = getSetting();
+export const request: RequestConfig = {
+ prefix: baseURL,
+ errorHandler,
+ credentials: 'same-origin',
+};
diff --git a/src/assets/logo.svg b/src/assets/logo.svg
new file mode 100644
index 0000000..ef5ec53
--- /dev/null
+++ b/src/assets/logo.svg
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg viewBox="0 0 500 500" xmlns="http://www.w3.org/2000/svg">
+ <defs>
+ <linearGradient id="id0" gradientUnits="userSpaceOnUse" x1="25119.8" y1="11052.5" x2="21725.7" y2="21551.7" gradientTransform="matrix(0.028095, 0, 0, 0.028095, -492.867096, -144.769821)">
+ <stop offset="0" style="stop-color:#A92F33"/>
+ <stop offset="1" style="stop-color:#E62129"/>
+ </linearGradient>
+ <linearGradient id="id1" gradientUnits="userSpaceOnUse" x1="27026.6" y1="8021.8" x2="30514.6" y2="16218.5" gradientTransform="matrix(0.028095, 0, 0, 0.028095, -492.867096, -144.769821)">
+ <stop offset="0" style="stop-color:#A92F33"/>
+ <stop offset="1" style="stop-color:#E8443F"/>
+ </linearGradient>
+ <linearGradient id="id2" gradientUnits="userSpaceOnUse" x1="23046.1" y1="14340.2" x2="26713.9" y2="9900.07" gradientTransform="matrix(0.028095, 0, 0, 0.028095, -492.867096, -144.769821)">
+ <stop offset="0" style="stop-color:#E62129"/>
+ <stop offset="1" style="stop-color:#E8443F"/>
+ </linearGradient>
+ </defs>
+ <path class="fil1" d="M 156.005 337.641 L 247.284 205.232 L 218.515 160.195 L 134.429 259.986 C 134.429 259.986 134.429 259.986 134.429 259.986 C 134.429 259.986 134.429 259.986 134.429 259.986 C 106.503 293.139 102.007 301.399 78.689 340.281 L 156.033 337.641 Z" style="fill: url(#id0);"/>
+ <path class="fil2" d="M 382.616 340.281 L 419.616 340.141 L 253.325 51.188 L 253.325 51.188 L 333.619 340.281 L 382.644 340.281 Z M 253.297 51.188 L 211.239 120.694 L 219.695 106.732 L 253.297 51.216 Z" style="fill: url(#id1);"/>
+ <polygon class="fil3" points="218.487 160.195 333.59 340.281 253.297 51.188 253.297 51.188 253.297 51.188 219.724 106.703 78.661 340.281" style="fill: url(#id2);"/>
+ <path d="M 116.292 266.849 L 116.292 278.269 L 111.252 278.269 L 111.252 277.099 C 111.119 277.392 110.855 277.659 110.462 277.899 C 110.062 278.146 109.702 278.269 109.382 278.269 L 95.732 278.269 C 95.365 278.269 94.995 278.176 94.622 277.989 C 94.249 277.796 93.945 277.526 93.712 277.179 C 93.479 276.839 93.359 276.452 93.352 276.019 C 93.345 275.192 93.335 274.146 93.322 272.879 C 93.309 271.612 93.302 270.946 93.302 270.879 C 93.302 269.932 93.555 269.189 94.062 268.649 C 94.562 2 [...]
+</svg>
\ No newline at end of file
diff --git a/src/components/Footer/index.tsx b/src/components/Footer/index.tsx
new file mode 100644
index 0000000..0f6648b
--- /dev/null
+++ b/src/components/Footer/index.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import { GithubOutlined } from '@ant-design/icons';
+import { DefaultFooter } from '@ant-design/pro-layout';
+
+export default () => (
+ <DefaultFooter
+ copyright="2020 Apache APISIX"
+ links={[
+ {
+ key: 'GitHub',
+ title: <GithubOutlined />,
+ href: 'https://github.com/apache/incubator-apisix',
+ blankTarget: true,
+ },
+ ]}
+ />
+);
diff --git a/src/components/HeaderDropdown/index.less b/src/components/HeaderDropdown/index.less
new file mode 100644
index 0000000..004b53e
--- /dev/null
+++ b/src/components/HeaderDropdown/index.less
@@ -0,0 +1,16 @@
+@import '~antd/es/style/themes/default.less';
+
+.container > * {
+ background-color: @popover-bg;
+ border-radius: 4px;
+ box-shadow: @shadow-1-down;
+}
+
+@media screen and (max-width: @screen-xs) {
+ .container {
+ width: 100% !important;
+ }
+ .container > * {
+ border-radius: 0 !important;
+ }
+}
diff --git a/src/components/HeaderDropdown/index.tsx b/src/components/HeaderDropdown/index.tsx
new file mode 100644
index 0000000..cc60727
--- /dev/null
+++ b/src/components/HeaderDropdown/index.tsx
@@ -0,0 +1,19 @@
+import { DropDownProps } from 'antd/es/dropdown';
+import { Dropdown } from 'antd';
+import React from 'react';
+import classNames from 'classnames';
+import styles from './index.less';
+
+declare type OverlayFunc = () => React.ReactNode;
+
+export interface HeaderDropdownProps extends Omit<DropDownProps, 'overlay'> {
+ overlayClassName?: string;
+ overlay: React.ReactNode | OverlayFunc | any;
+ placement?: 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topCenter' | 'topRight' | 'bottomCenter';
+}
+
+const HeaderDropdown: React.FC<HeaderDropdownProps> = ({ overlayClassName: cls, ...restProps }) => (
+ <Dropdown overlayClassName={classNames(styles.container, cls)} {...restProps} />
+);
+
+export default HeaderDropdown;
diff --git a/src/components/NoticeIcon/NoticeList.less b/src/components/NoticeIcon/NoticeList.less
new file mode 100755
index 0000000..1aba610
--- /dev/null
+++ b/src/components/NoticeIcon/NoticeList.less
@@ -0,0 +1,103 @@
+@import '~antd/es/style/themes/default.less';
+
+.list {
+ max-height: 400px;
+ overflow: auto;
+ &::-webkit-scrollbar {
+ display: none;
+ }
+ .item {
+ padding-right: 24px;
+ padding-left: 24px;
+ overflow: hidden;
+ cursor: pointer;
+ transition: all 0.3s;
+
+ .meta {
+ width: 100%;
+ }
+
+ .avatar {
+ margin-top: 4px;
+ background: #fff;
+ }
+ .iconElement {
+ font-size: 32px;
+ }
+
+ &.read {
+ opacity: 0.4;
+ }
+ &:last-child {
+ border-bottom: 0;
+ }
+ &:hover {
+ background: @primary-1;
+ }
+ .title {
+ margin-bottom: 8px;
+ font-weight: normal;
+ }
+ .description {
+ font-size: 12px;
+ line-height: @line-height-base;
+ }
+ .datetime {
+ margin-top: 4px;
+ font-size: 12px;
+ line-height: @line-height-base;
+ }
+ .extra {
+ float: right;
+ margin-top: -1.5px;
+ margin-right: 0;
+ color: @text-color-secondary;
+ font-weight: normal;
+ }
+ }
+ .loadMore {
+ padding: 8px 0;
+ color: @primary-6;
+ text-align: center;
+ cursor: pointer;
+ &.loadedAll {
+ color: rgba(0, 0, 0, 0.25);
+ cursor: unset;
+ }
+ }
+}
+
+.notFound {
+ padding: 73px 0 88px;
+ color: @text-color-secondary;
+ text-align: center;
+ img {
+ display: inline-block;
+ height: 76px;
+ margin-bottom: 16px;
+ }
+}
+
+.bottomBar {
+ height: 46px;
+ color: @text-color;
+ line-height: 46px;
+ text-align: center;
+ border-top: 1px solid @border-color-split;
+ border-radius: 0 0 @border-radius-base @border-radius-base;
+ transition: all 0.3s;
+ div {
+ display: inline-block;
+ width: 50%;
+ cursor: pointer;
+ transition: all 0.3s;
+ user-select: none;
+
+ &:only-child {
+ width: 100%;
+ }
+ &:not(:only-child):last-child {
+ border-left: 1px solid @border-color-split;
+ }
+ }
+}
diff --git a/src/components/NoticeIcon/NoticeList.tsx b/src/components/NoticeIcon/NoticeList.tsx
new file mode 100644
index 0000000..8d00be2
--- /dev/null
+++ b/src/components/NoticeIcon/NoticeList.tsx
@@ -0,0 +1,113 @@
+import { Avatar, List } from 'antd';
+
+import React from 'react';
+import classNames from 'classnames';
+import styles from './NoticeList.less';
+
+export interface NoticeIconTabProps {
+ count?: number;
+ name?: string;
+ showClear?: boolean;
+ showViewMore?: boolean;
+ style?: React.CSSProperties;
+ title: string;
+ tabKey: string;
+ data?: API.NoticeIconData[];
+ onClick?: (item: API.NoticeIconData) => void;
+ onClear?: () => void;
+ emptyText?: string;
+ clearText?: string;
+ viewMoreText?: string;
+ list: API.NoticeIconData[];
+ onViewMore?: (e: any) => void;
+}
+const NoticeList: React.SFC<NoticeIconTabProps> = ({
+ data = [],
+ onClick,
+ onClear,
+ title,
+ onViewMore,
+ emptyText,
+ showClear = true,
+ clearText,
+ viewMoreText,
+ showViewMore = false,
+}) => {
+ if (data.length === 0) {
+ return (
+ <div className={styles.notFound}>
+ <img
+ src="https://gw.alipayobjects.com/zos/rmsportal/sAuJeJzSKbUmHfBQRzmZ.svg"
+ alt="not found"
+ />
+ <div>{emptyText}</div>
+ </div>
+ );
+ }
+ return (
+ <div>
+ <List<NoticeIconData>
+ className={styles.list}
+ dataSource={data}
+ renderItem={(item, i) => {
+ const itemCls = classNames(styles.item, {
+ [styles.read]: item.read,
+ });
+ // eslint-disable-next-line no-nested-ternary
+ const leftIcon = item.avatar ? (
+ typeof item.avatar === 'string' ? (
+ <Avatar className={styles.avatar} src={item.avatar} />
+ ) : (
+ <span className={styles.iconElement}>{item.avatar}</span>
+ )
+ ) : null;
+
+ return (
+ <List.Item
+ className={itemCls}
+ key={item.key || i}
+ onClick={() => onClick && onClick(item)}
+ >
+ <List.Item.Meta
+ className={styles.meta}
+ avatar={leftIcon}
+ title={
+ <div className={styles.title}>
+ {item.title}
+ <div className={styles.extra}>{item.extra}</div>
+ </div>
+ }
+ description={
+ <div>
+ <div className={styles.description}>{item.description}</div>
+ <div className={styles.datetime}>{item.datetime}</div>
+ </div>
+ }
+ />
+ </List.Item>
+ );
+ }}
+ />
+ <div className={styles.bottomBar}>
+ {showClear ? (
+ <div onClick={onClear}>
+ {clearText} {title}
+ </div>
+ ) : null}
+ {showViewMore ? (
+ <div
+ onClick={(e) => {
+ if (onViewMore) {
+ onViewMore(e);
+ }
+ }}
+ >
+ {viewMoreText}
+ </div>
+ ) : null}
+ </div>
+ </div>
+ );
+};
+
+export default NoticeList;
diff --git a/src/components/NoticeIcon/index.less b/src/components/NoticeIcon/index.less
new file mode 100644
index 0000000..650ccd2
--- /dev/null
+++ b/src/components/NoticeIcon/index.less
@@ -0,0 +1,31 @@
+@import '~antd/es/style/themes/default.less';
+
+.popover {
+ position: relative;
+ width: 336px;
+}
+
+.noticeButton {
+ display: inline-block;
+ cursor: pointer;
+ transition: all 0.3s;
+}
+.icon {
+ padding: 4px;
+ vertical-align: middle;
+}
+
+.badge {
+ font-size: 16px;
+}
+
+.tabs {
+ :global {
+ .ant-tabs-nav-scroll {
+ text-align: center;
+ }
+ .ant-tabs-bar {
+ margin-bottom: 0;
+ }
+ }
+}
diff --git a/src/components/NoticeIcon/index.tsx b/src/components/NoticeIcon/index.tsx
new file mode 100644
index 0000000..0df4061
--- /dev/null
+++ b/src/components/NoticeIcon/index.tsx
@@ -0,0 +1,124 @@
+import { BellOutlined } from '@ant-design/icons';
+import { Badge, Spin, Tabs } from 'antd';
+import useMergeValue from 'use-merge-value';
+import React from 'react';
+import classNames from 'classnames';
+import NoticeList, { NoticeIconTabProps } from './NoticeList';
+
+import HeaderDropdown from '../HeaderDropdown';
+import styles from './index.less';
+
+const { TabPane } = Tabs;
+
+export interface NoticeIconProps {
+ count?: number;
+ bell?: React.ReactNode;
+ className?: string;
+ loading?: boolean;
+ onClear?: (tabName: string, tabKey: string) => void;
+ onItemClick?: (item: API.NoticeIconData, tabProps: NoticeIconTabProps) => void;
+ onViewMore?: (tabProps: NoticeIconTabProps, e: MouseEvent) => void;
+ onTabChange?: (tabTile: string) => void;
+ style?: React.CSSProperties;
+ onPopupVisibleChange?: (visible: boolean) => void;
+ popupVisible?: boolean;
+ clearText?: string;
+ viewMoreText?: string;
+ clearClose?: boolean;
+ emptyImage?: string;
+ children: React.ReactElement<NoticeIconTabProps>[];
+}
+
+const NoticeIcon: React.FC<NoticeIconProps> & {
+ Tab: typeof NoticeList;
+} = (props) => {
+ const getNotificationBox = (): React.ReactNode => {
+ const {
+ children,
+ loading,
+ onClear,
+ onTabChange,
+ onItemClick,
+ onViewMore,
+ clearText,
+ viewMoreText,
+ } = props;
+ if (!children) {
+ return null;
+ }
+ const panes: React.ReactNode[] = [];
+ React.Children.forEach(children, (child: React.ReactElement<NoticeIconTabProps>): void => {
+ if (!child) {
+ return;
+ }
+ const { list, title, count, tabKey, showClear, showViewMore } = child.props;
+ const len = list && list.length ? list.length : 0;
+ const msgCount = count || count === 0 ? count : len;
+ const tabTitle: string = msgCount > 0 ? `${title} (${msgCount})` : title;
+ panes.push(
+ <TabPane tab={tabTitle} key={tabKey}>
+ <NoticeList
+ clearText={clearText}
+ viewMoreText={viewMoreText}
+ data={list}
+ onClear={(): void => onClear && onClear(title, tabKey)}
+ onClick={(item): void => onItemClick && onItemClick(item, child.props)}
+ onViewMore={(event): void => onViewMore && onViewMore(child.props, event)}
+ showClear={showClear}
+ showViewMore={showViewMore}
+ title={title}
+ {...child.props}
+ />
+ </TabPane>,
+ );
+ });
+ return (
+ <Spin spinning={loading} delay={300}>
+ <Tabs className={styles.tabs} onChange={onTabChange}>
+ {panes}
+ </Tabs>
+ </Spin>
+ );
+ };
+
+ const { className, count, bell } = props;
+
+ const [visible, setVisible] = useMergeValue<boolean>(false, {
+ value: props.popupVisible,
+ onChange: props.onPopupVisibleChange,
+ });
+ const noticeButtonClass = classNames(className, styles.noticeButton);
+ const notificationBox = getNotificationBox();
+ const NoticeBellIcon = bell || <BellOutlined className={styles.icon} />;
+ const trigger = (
+ <span className={classNames(noticeButtonClass, { opened: visible })}>
+ <Badge count={count} style={{ boxShadow: 'none' }} className={styles.badge}>
+ {NoticeBellIcon}
+ </Badge>
+ </span>
+ );
+ if (!notificationBox) {
+ return trigger;
+ }
+
+ return (
+ <HeaderDropdown
+ placement="bottomRight"
+ overlay={notificationBox}
+ overlayClassName={styles.popover}
+ trigger={['click']}
+ visible={visible}
+ onVisibleChange={setVisible}
+ >
+ {trigger}
+ </HeaderDropdown>
+ );
+};
+
+NoticeIcon.defaultProps = {
+ emptyImage: 'https://gw.alipayobjects.com/zos/rmsportal/wAhyIChODzsoKIOBHcBk.svg',
+};
+
+NoticeIcon.Tab = NoticeList;
+
+export default NoticeIcon;
diff --git a/src/components/PageLoading/index.tsx b/src/components/PageLoading/index.tsx
new file mode 100644
index 0000000..096c58f
--- /dev/null
+++ b/src/components/PageLoading/index.tsx
@@ -0,0 +1,5 @@
+import { PageLoading } from '@ant-design/pro-layout';
+
+// loading components from code split
+// https://umijs.org/plugin/umi-plugin-react.html#dynamicimport
+export default PageLoading;
diff --git a/src/components/PluginForm/PluginForm.tsx b/src/components/PluginForm/PluginForm.tsx
new file mode 100644
index 0000000..5b69bec
--- /dev/null
+++ b/src/components/PluginForm/PluginForm.tsx
@@ -0,0 +1,192 @@
+import React, { useState, useEffect } from 'react';
+import { Form, Input, Switch, Select, InputNumber, Button } from 'antd';
+import { PlusOutlined, MinusCircleOutlined } from '@ant-design/icons';
+import { useIntl } from 'umi';
+
+import { fetchPluginSchema } from './service';
+import { transformPropertyToRules } from './transformer';
+
+const formLayout = {
+ labelCol: { span: 10 },
+ wrapperCol: { span: 14 },
+};
+
+interface RenderComponentProps {
+ placeholder?: string;
+ disabled?: boolean;
+}
+
+const renderComponentByProperty = (
+ propertyValue: PluginForm.PluginProperty,
+ restProps?: RenderComponentProps,
+) => {
+ const { type, minimum, maximum } = propertyValue;
+
+ if (type === 'string') {
+ if (propertyValue.enum) {
+ return (
+ <Select disabled={restProps?.disabled}>
+ {propertyValue.enum.map((enumValue) => (
+ <Select.Option value={enumValue} key={enumValue}>
+ {enumValue}
+ </Select.Option>
+ ))}
+ </Select>
+ );
+ }
+ return <Input {...restProps} />;
+ }
+
+ if (type === 'boolean') {
+ return <Switch {...restProps} />;
+ }
+
+ if (type === 'number' || type === 'integer') {
+ return (
+ <InputNumber
+ min={minimum ?? Number.MIN_SAFE_INTEGER}
+ max={maximum ?? Number.MAX_SAFE_INTEGER}
+ {...restProps}
+ />
+ );
+ }
+
+ return <Input {...restProps} />;
+};
+
+interface ArrayComponentProps {
+ disabled?: boolean;
+ schema: PluginForm.PluginSchema;
+ propertyName: string;
+ propertyValue: PluginForm.PluginProperty;
+}
+
+const ArrayComponent: React.FC<ArrayComponentProps> = ({
+ propertyName,
+ propertyValue,
+ schema,
+ disabled,
+ children,
+}) => {
+ const { formatMessage } = useIntl();
+ return (
+ <Form.List key={propertyName} name={propertyName}>
+ {(fields, { add, remove }) => (
+ <>
+ {fields.map((field, index) => (
+ <Form.Item
+ key={field.key}
+ rules={transformPropertyToRules(schema!, propertyName, propertyValue)}
+ label={`${propertyName}-${index + 1}`}
+ >
+ {children}
+ {fields.length > 1 ? (
+ <MinusCircleOutlined onClick={() => remove(field.name)} />
+ ) : (
+ <React.Fragment />
+ )}
+ </Form.Item>
+ ))}
+ {/* BUG: There should also care about minItems */}
+ {fields.length < (propertyValue.maxItems ?? Number.MAX_SAFE_INTEGER) ? (
+ <Form.Item label={propertyName}>
+ <Button type="dashed" onClick={add} disabled={disabled}>
+ <PlusOutlined /> {formatMessage({ id: 'component.global.add' })}
+ </Button>
+ </Form.Item>
+ ) : null}
+ </>
+ )}
+ </Form.List>
+ );
+};
+
+const PluginForm: React.FC<PluginForm.Props> = ({
+ name,
+ form,
+ disabled,
+ initialData = {},
+ onFinish,
+}) => {
+ const { formatMessage } = useIntl();
+ const [schema, setSchema] = useState<PluginForm.PluginSchema>();
+
+ useEffect(() => {
+ if (name) {
+ setSchema(undefined);
+ fetchPluginSchema(name).then((data) => {
+ setSchema(data);
+
+ const propertyDefaultData = {};
+ Object.entries(data.properties || {}).forEach(([propertyName, propertyValue]) => {
+ if (propertyValue.hasOwnProperty('default')) {
+ propertyDefaultData[propertyName] = propertyValue.default;
+ }
+ });
+ form.setFieldsValue(propertyDefaultData);
+
+ requestAnimationFrame(() => {
+ form.setFieldsValue(initialData);
+ });
+ });
+ }
+ }, [name]);
+
+ return (
+ <Form {...formLayout} form={form} onFinish={onFinish} labelAlign="left">
+ {Object.entries(schema?.properties || {}).map(([propertyName, propertyValue]) => {
+ // eslint-disable-next-line arrow-body-style
+ if (propertyValue.type === 'array') {
+ return (
+ <ArrayComponent
+ key={propertyName}
+ disabled={disabled}
+ schema={schema!}
+ propertyName={propertyName}
+ propertyValue={propertyValue}
+ >
+ {renderComponentByProperty({ type: 'string' })}
+ </ArrayComponent>
+ );
+ }
+
+ if (propertyValue.type === 'object') {
+ return (
+ <ArrayComponent
+ key={propertyName}
+ disabled={disabled}
+ schema={schema!}
+ propertyName={propertyName}
+ propertyValue={propertyValue}
+ >
+ {/* TODO: there should not be fixed value, and it should receive custom key */}
+ {renderComponentByProperty({ type: 'string' }, { placeholder: 'Header' })}
+ {renderComponentByProperty({ type: 'string' }, { placeholder: 'Value' })}
+ </ArrayComponent>
+ );
+ }
+
+ return (
+ <Form.Item
+ label={formatMessage({
+ id: `PluginForm.plugin.${name}.property.${propertyName}`,
+ defaultMessage: propertyName,
+ })}
+ extra={formatMessage({
+ id: `PluginForm.plugin.${name}.property.${propertyName}.extra`,
+ defaultMessage: '',
+ })}
+ name={propertyName}
+ key={propertyName}
+ rules={transformPropertyToRules(schema!, propertyName, propertyValue)}
+ valuePropName={propertyValue.type === 'boolean' ? 'checked' : 'value'}
+ >
+ {renderComponentByProperty(propertyValue, { disabled })}
+ </Form.Item>
+ );
+ })}
+ </Form>
+ );
+};
+
+export default PluginForm;
diff --git a/src/components/PluginForm/README.md b/src/components/PluginForm/README.md
new file mode 100644
index 0000000..68d4686
--- /dev/null
+++ b/src/components/PluginForm/README.md
@@ -0,0 +1,9 @@
+# PluginForm
+
+> The PluginForm component aims to build UI according to the plugin schema.
+
+## Usage
+
+1. Modify `list` variable in `data.ts` file.
+2. Modify filds under `/locales` folder to have a good translation.
+3. Import files under `/locales` folder to `/src/locales` to be localization.
diff --git a/src/components/PluginForm/data.ts b/src/components/PluginForm/data.ts
new file mode 100644
index 0000000..d8dd6e3
--- /dev/null
+++ b/src/components/PluginForm/data.ts
@@ -0,0 +1,100 @@
+export const PLUGIN_MAPPER_SOURCE: { [name: string]: PluginForm.PluginMapperItem } = {
+ 'limit-req': {
+ category: 'Limit',
+ },
+ 'limit-count': {
+ category: 'Limit',
+ },
+ 'limit-conn': {
+ category: 'Limit',
+ },
+ 'key-auth': {
+ category: 'Security',
+ hidden: true,
+ },
+ 'basic-auth': {
+ category: 'Security',
+ hidden: true,
+ },
+ prometheus: {
+ category: 'Metric',
+ },
+ 'node-status': {
+ category: 'Other',
+ },
+ 'jwt-auth': {
+ category: 'Security',
+ hidden: true,
+ },
+ zipkin: {
+ category: 'Metric',
+ },
+ 'ip-restriction': {
+ category: 'Security',
+ },
+ 'grpc-transcode': {
+ category: 'Other',
+ hidden: true,
+ },
+ 'serverless-pre-function': {
+ category: 'Other',
+ },
+ 'serverless-post-function': {
+ category: 'Other',
+ },
+ 'openid-connect': {
+ category: 'Security',
+ },
+ 'proxy-rewrite': {
+ category: 'Other',
+ hidden: true,
+ },
+ redirect: {
+ category: 'Other',
+ hidden: true,
+ },
+ 'response-rewrite': {
+ category: 'Other',
+ },
+ 'fault-injection': {
+ category: 'Security',
+ },
+ 'udp-logger': {
+ category: 'Log',
+ },
+ 'wolf-rbac': {
+ category: 'Other',
+ hidden: true,
+ },
+ 'proxy-cache': {
+ category: 'Other',
+ },
+ 'tcp-logger': {
+ category: 'Log',
+ },
+ 'proxy-mirror': {
+ category: 'Other',
+ },
+ 'kafka-logger': {
+ category: 'Log',
+ },
+ cors: {
+ category: 'Security',
+ },
+ heartbeat: {
+ category: 'Other',
+ hidden: true,
+ },
+ 'batch-requests': {
+ category: 'Other',
+ },
+ 'http-logger': {
+ category: 'Log',
+ },
+ 'mqtt-proxy': {
+ category: 'Other',
+ },
+ oauth: {
+ category: 'Security',
+ },
+};
diff --git a/src/components/PluginForm/index.ts b/src/components/PluginForm/index.ts
new file mode 100644
index 0000000..8770cd6
--- /dev/null
+++ b/src/components/PluginForm/index.ts
@@ -0,0 +1,3 @@
+export { default } from './PluginForm';
+export { default as PluginFormZhCN } from './locales/zh-CN';
+export { default as PluginFormEnUS } from './locales/en-US';
diff --git a/src/components/PluginForm/locales/en-US.ts b/src/components/PluginForm/locales/en-US.ts
new file mode 100644
index 0000000..4f19e6d
--- /dev/null
+++ b/src/components/PluginForm/locales/en-US.ts
@@ -0,0 +1,152 @@
+export default {
+ 'PluginForm.plugin.limit-conn.desc': '限制并发连接数',
+ 'PluginForm.plugin.limit-conn.property.conn': 'conn',
+ 'PluginForm.plugin.limit-conn.property.conn.extra': '最大并发连接数',
+ 'PluginForm.plugin.limit-conn.property.burst': 'burst',
+ 'PluginForm.plugin.limit-conn.property.burst.extra': '并发连接数超过 conn,但是低于 conn + burst 时,请求将被延迟处理',
+ 'PluginForm.plugin.limit-conn.property.default_conn_delay': '延迟时间',
+ 'PluginForm.plugin.limit-conn.property.default_conn_delay.extra': '被延迟处理的请求,需要等待多少秒',
+ 'PluginForm.plugin.limit-conn.property.key': 'key',
+ 'PluginForm.plugin.limit-conn.property.key.extra': '用来做限制的依据',
+ 'PluginForm.plugin.limit-conn.property.rejected_code': '拒绝状态码',
+ 'PluginForm.plugin.limit-conn.property.rejected_code.extra': '当并发连接数超过 conn + burst 的限制时,返回给终端的 HTTP 状态码',
+
+ 'PluginForm.plugin.limit-count.desc': '在指定的时间范围内,限制总的请求次数',
+ 'PluginForm.plugin.limit-count.property.count': '总请求次数',
+ 'PluginForm.plugin.limit-count.property.count.extra': '指定时间窗口内的请求数量阈值',
+ 'PluginForm.plugin.limit-count.property.time_window': '时间窗口',
+ 'PluginForm.plugin.limit-count.property.time_window.extra':
+ '时间窗口的大小(以秒为单位),超过这个时间,总请求次数就会重置',
+ 'PluginForm.plugin.limit-count.property.key': 'key',
+ 'PluginForm.plugin.limit-count.property.key.extra': '用来做请求计数的依据',
+ 'PluginForm.plugin.limit-count.property.rejected_code': '拒绝状态码',
+ 'PluginForm.plugin.limit-count.property.rejected_code.extra':
+ '当请求超过阈值时,返回给终端的 HTTP 状态码',
+ 'PluginForm.plugin.limit-count.property.policy': '策略',
+ 'PluginForm.plugin.limit-count.property.redis_host': '地址',
+ 'PluginForm.plugin.limit-count.property.redis_host.extra': '用于集群限流的 Redis 节点地址',
+ 'PluginForm.plugin.limit-count.property.redis_port': '端口',
+ 'PluginForm.plugin.limit-count.property.redis_password': '密码',
+ 'PluginForm.plugin.limit-count.property.redis_timeout': '超时时间(毫秒)',
+
+ 'PluginForm.plugin.limit-req.desc': '限制请求速度的插件,基于漏桶算法',
+ 'PluginForm.plugin.limit-req.property.rate': 'rate',
+ 'PluginForm.plugin.limit-req.property.rate.extra': '每秒请求速率',
+ 'PluginForm.plugin.limit-req.property.burst': 'burst',
+ 'PluginForm.plugin.limit-req.property.burst.extra':
+ '每秒请求速率超过 rate,但是低于 rate + burst 时,请求将被延迟处理',
+ 'PluginForm.plugin.limit-req.property.key': 'key',
+ 'PluginForm.plugin.limit-req.property.key.extra': '用来做请求计数的依据',
+ 'PluginForm.plugin.limit-req.property.rejected_code': '拒绝状态码',
+ 'PluginForm.plugin.limit-req.property.rejected_code.extra':
+ '速率超过 rate + burst 的限制时,返回给终端的 HTTP 状态码',
+
+ 'PluginForm.plugin.cors.desc': 'CORS 插件可以为服务端启用 CORS 的返回头',
+ 'PluginForm.plugin.cors.property.allow_origins': '允许跨域访问的 Origin',
+ 'PluginForm.plugin.cors.property.allow_origins.extra':
+ '比如 https://somehost.com:8081',
+ 'PluginForm.plugin.cors.property.allow_methods': '允许跨域访问的 Method',
+
+ 'PluginForm.plugin.fault-injection.desc': '故障注入插件,用来模拟各种后端故障和高延迟',
+ 'PluginForm.plugin.fault-injection.property.http_status': 'HTTP 状态码',
+ 'PluginForm.plugin.fault-injection.property.body': '响应体',
+ 'PluginForm.plugin.fault-injection.property.duration': '延迟时间(秒)',
+
+ 'PluginForm.plugin.http-logger.desc': 'http-logger 可以将日志数据请求推送到 HTTP/HTTPS 服务器',
+ 'PluginForm.plugin.http-logger.property.uri': '日志服务器地址',
+ 'PluginForm.plugin.http-logger.property.uri.extra': '比如 127.0.0.1:80/postendpoint?param=1',
+
+ 'PluginForm.plugin.ip-restriction.desc':
+ 'ip-restriction 可以把一批 IP 地址列入白名单或黑名单(二选一),时间复杂度是O(1),并支持用 CIDR 来表示 IP 范围',
+ 'PluginForm.plugin.ip-restriction.property.whitelist': '白名单',
+ 'PluginForm.plugin.ip-restriction.property.blacklist': '黑名单',
+
+ 'PluginForm.plugin.kafka-logger.desc': '把接口请求日志以 JSON 的形式推送给外部 Kafka 集群',
+ 'PluginForm.plugin.kafka-logger.property.broker_list': 'broker',
+ 'PluginForm.plugin.kafka-logger.property.kafka_topic': 'topic',
+
+ 'PluginForm.plugin.prometheus.desc': '提供符合 prometheus 数据格式的 metrics 数据',
+
+ 'PluginForm.plugin.proxy-cache.desc': '代理缓存插件,缓存后端服务的响应数据',
+ 'PluginForm.plugin.proxy-cache.property.cache_zone': '缓存区域名',
+ 'PluginForm.plugin.proxy-cache.property.cache_zone.extra': ' 本地目录为 /tmp/${区域名},修改默认区域名必须同时修改 config.yaml',
+ 'PluginForm.plugin.proxy-cache.property.cache_key': '缓存 key',
+ 'PluginForm.plugin.proxy-cache.property.cache_key.extra': '可以使用 Nginx 变量,例如:$host, $uri',
+ 'PluginForm.plugin.proxy-cache.property.cache_bypass': '跳过缓存检索',
+ 'PluginForm.plugin.proxy-cache.property.cache_bypass.extra': '这里可以使用 Nginx 变量,当此参数的值不为空或非0时将会跳过缓存的检索',
+ 'PluginForm.plugin.proxy-cache.property.cache_method': '缓存 Method',
+ 'PluginForm.plugin.proxy-cache.property.cache_http_status': '缓存响应状态码',
+ 'PluginForm.plugin.proxy-cache.property.hide_cache_headers': '隐藏缓存头',
+ 'PluginForm.plugin.proxy-cache.property.hide_cache_headers.extra': '是否将 Expires 和 Cache-Control 响应头返回给客户端',
+ 'PluginForm.plugin.proxy-cache.property.no_cache': '不缓存的数据',
+ 'PluginForm.plugin.proxy-cache.property.no_cache.extra': '这里可以使用 Nginx 变量, 当此参数的值不为空或非0时将不会缓存数据',
+
+ 'PluginForm.plugin.proxy-mirror.desc': 'proxy mirror 代理镜像插件,提供了镜像客户端请求的能力',
+ 'PluginForm.plugin.proxy-mirror.property.host': '镜像服务地址',
+ 'PluginForm.plugin.proxy-mirror.property.host.extra': '例如:http://127.0.0.1:9797。地址中需要包含 http 或 https,不能包含 URI 部分',
+
+ 'PluginForm.plugin.response-rewrite.desc': '该插件支持修改上游服务返回的 body 和 header 信息',
+ 'PluginForm.plugin.response-rewrite.property.status_code': '状态码',
+ 'PluginForm.plugin.response-rewrite.property.body': '响应体',
+ 'PluginForm.plugin.response-rewrite.property.body_base64': '响应体是否需要 base64 解码',
+ 'PluginForm.plugin.response-rewrite.property.headers': 'HTTP 头',
+
+ 'PluginForm.plugin.syslog.desc': '对接 syslog 日志服务器',
+ 'PluginForm.plugin.syslog.property.host': '日志服务器地址',
+ 'PluginForm.plugin.syslog.property.port': '日志服务器端口',
+ 'PluginForm.plugin.syslog.property.timeout': '超时时间',
+ 'PluginForm.plugin.syslog.property.tls': '开启 SSL',
+ 'PluginForm.plugin.syslog.property.flush_limit': '缓存区大小',
+ 'PluginForm.plugin.syslog.property.sock_type': '协议类型',
+ 'PluginForm.plugin.syslog.property.max_retry_times': '重试次数',
+ 'PluginForm.plugin.syslog.property.retry_interval': '重试间隔时间(毫秒)',
+ 'PluginForm.plugin.syslog.property.pool_size': '连接池大小',
+
+ 'PluginForm.plugin.tcp-logger.desc': '对接 TCP 日志服务器',
+ 'PluginForm.plugin.tcp-logger.property.host': '日志服务器地址',
+ 'PluginForm.plugin.tcp-logger.property.port': '日志服务器地址',
+ 'PluginForm.plugin.tcp-logger.property.timeout': '超时时间',
+ 'PluginForm.plugin.tcp-logger.property.tls': '开启 SSL',
+ 'PluginForm.plugin.tcp-logger.property.tls_options': 'TLS 选型',
+
+ 'PluginForm.plugin.udp-logger.desc': '对接 UDP 日志服务器',
+ 'PluginForm.plugin.udp-logger.property.host': '日志服务器地址',
+ 'PluginForm.plugin.udp-logger.property.port': '日志服务器地址',
+ 'PluginForm.plugin.udp-logger.property.timeout': '超时时间',
+
+ 'PluginForm.plugin.zipkin.desc': '对接 zipkin',
+ 'PluginForm.plugin.zipkin.property.endpoint': 'endpoint',
+ 'PluginForm.plugin.zipkin.property.endpoint.extra': '例如 http://127.0.0.1:9411/api/v2/spans',
+ 'PluginForm.plugin.zipkin.property.sample_ratio': '采样率',
+ 'PluginForm.plugin.zipkin.property.service_name': '服务名',
+ 'PluginForm.plugin.zipkin.property.server_addr': '网关实例 IP',
+ 'PluginForm.plugin.zipkin.property.server_addr.extra': '默认值是 Nginx 内置变量 server_addr',
+
+ 'PluginForm.plugin.skywalking.desc': '对接 Apache Skywalking',
+ 'PluginForm.plugin.skywalking.property.endpoint': 'endpoint',
+ 'PluginForm.plugin.skywalking.property.endpoint.extra': '例如 http://127.0.0.1:12800',
+ 'PluginForm.plugin.skywalking.property.sample_ratio': '采样率',
+ 'PluginForm.plugin.skywalking.property.service_name': '服务名',
+
+ 'PluginForm.plugin.serverless-pre-function.desc': '在指定阶段最开始的位置,运行指定的 Lua 函数',
+ 'PluginForm.plugin.serverless-pre-function.property.phase': '运行阶段',
+ 'PluginForm.plugin.serverless-pre-function.property.functions': '运行的函数集',
+
+ 'PluginForm.plugin.serverless-post-function.desc': '在指定阶段最后的位置,运行指定的 Lua 函数',
+ 'PluginForm.plugin.serverless-post-function.property.phase': '运行阶段',
+ 'PluginForm.plugin.serverless-post-function.property.functions': '运行的函数集',
+
+
+ 'PluginForm.plugin.basic-auth.desc': 'basic auth 插件',
+ 'PluginForm.plugin.jwt-auth.desc': 'JWT 认证插件',
+ 'PluginForm.plugin.key-auth.desc': 'key auth 插件',
+ 'PluginForm.plugin.wolf-rbac.desc': '对接 wolf RBAC 服务',
+ 'PluginForm.plugin.openid-connect.desc': 'Open ID Connect(OIDC) 插件提供对接外部认证服务的能力',
+
+ 'PluginForm.plugin.redirect.desc': '重定向插件',
+ 'PluginForm.plugin.proxy-rewrite.desc': 'proxy rewrite 代理改写插件,可以改写客户端请求',
+ 'PluginForm.plugin.mqtt-proxy.desc': 'mqtt-proxy 插件可以帮助你根据 MQTT 的 client_id 实现动态负载均衡',
+ 'PluginForm.plugin.grpc-transcoding.desc': 'gRPC 转换插件,实现 HTTP(s) -> APISIX -> gRPC server 的转换',
+ 'PluginForm.plugin.batch-requests.desc':
+ 'batch-requests 插件可以一次接受多个请求并以 http pipeline 的方式在网关发起多个 http 请求,合并结果后再返回客户端,这在客户端需要访问多个接口时可以显著地提升请求性能',
+};
diff --git a/src/components/PluginForm/locales/zh-CN.ts b/src/components/PluginForm/locales/zh-CN.ts
new file mode 100644
index 0000000..4f19e6d
--- /dev/null
+++ b/src/components/PluginForm/locales/zh-CN.ts
@@ -0,0 +1,152 @@
+export default {
+ 'PluginForm.plugin.limit-conn.desc': '限制并发连接数',
+ 'PluginForm.plugin.limit-conn.property.conn': 'conn',
+ 'PluginForm.plugin.limit-conn.property.conn.extra': '最大并发连接数',
+ 'PluginForm.plugin.limit-conn.property.burst': 'burst',
+ 'PluginForm.plugin.limit-conn.property.burst.extra': '并发连接数超过 conn,但是低于 conn + burst 时,请求将被延迟处理',
+ 'PluginForm.plugin.limit-conn.property.default_conn_delay': '延迟时间',
+ 'PluginForm.plugin.limit-conn.property.default_conn_delay.extra': '被延迟处理的请求,需要等待多少秒',
+ 'PluginForm.plugin.limit-conn.property.key': 'key',
+ 'PluginForm.plugin.limit-conn.property.key.extra': '用来做限制的依据',
+ 'PluginForm.plugin.limit-conn.property.rejected_code': '拒绝状态码',
+ 'PluginForm.plugin.limit-conn.property.rejected_code.extra': '当并发连接数超过 conn + burst 的限制时,返回给终端的 HTTP 状态码',
+
+ 'PluginForm.plugin.limit-count.desc': '在指定的时间范围内,限制总的请求次数',
+ 'PluginForm.plugin.limit-count.property.count': '总请求次数',
+ 'PluginForm.plugin.limit-count.property.count.extra': '指定时间窗口内的请求数量阈值',
+ 'PluginForm.plugin.limit-count.property.time_window': '时间窗口',
+ 'PluginForm.plugin.limit-count.property.time_window.extra':
+ '时间窗口的大小(以秒为单位),超过这个时间,总请求次数就会重置',
+ 'PluginForm.plugin.limit-count.property.key': 'key',
+ 'PluginForm.plugin.limit-count.property.key.extra': '用来做请求计数的依据',
+ 'PluginForm.plugin.limit-count.property.rejected_code': '拒绝状态码',
+ 'PluginForm.plugin.limit-count.property.rejected_code.extra':
+ '当请求超过阈值时,返回给终端的 HTTP 状态码',
+ 'PluginForm.plugin.limit-count.property.policy': '策略',
+ 'PluginForm.plugin.limit-count.property.redis_host': '地址',
+ 'PluginForm.plugin.limit-count.property.redis_host.extra': '用于集群限流的 Redis 节点地址',
+ 'PluginForm.plugin.limit-count.property.redis_port': '端口',
+ 'PluginForm.plugin.limit-count.property.redis_password': '密码',
+ 'PluginForm.plugin.limit-count.property.redis_timeout': '超时时间(毫秒)',
+
+ 'PluginForm.plugin.limit-req.desc': '限制请求速度的插件,基于漏桶算法',
+ 'PluginForm.plugin.limit-req.property.rate': 'rate',
+ 'PluginForm.plugin.limit-req.property.rate.extra': '每秒请求速率',
+ 'PluginForm.plugin.limit-req.property.burst': 'burst',
+ 'PluginForm.plugin.limit-req.property.burst.extra':
+ '每秒请求速率超过 rate,但是低于 rate + burst 时,请求将被延迟处理',
+ 'PluginForm.plugin.limit-req.property.key': 'key',
+ 'PluginForm.plugin.limit-req.property.key.extra': '用来做请求计数的依据',
+ 'PluginForm.plugin.limit-req.property.rejected_code': '拒绝状态码',
+ 'PluginForm.plugin.limit-req.property.rejected_code.extra':
+ '速率超过 rate + burst 的限制时,返回给终端的 HTTP 状态码',
+
+ 'PluginForm.plugin.cors.desc': 'CORS 插件可以为服务端启用 CORS 的返回头',
+ 'PluginForm.plugin.cors.property.allow_origins': '允许跨域访问的 Origin',
+ 'PluginForm.plugin.cors.property.allow_origins.extra':
+ '比如 https://somehost.com:8081',
+ 'PluginForm.plugin.cors.property.allow_methods': '允许跨域访问的 Method',
+
+ 'PluginForm.plugin.fault-injection.desc': '故障注入插件,用来模拟各种后端故障和高延迟',
+ 'PluginForm.plugin.fault-injection.property.http_status': 'HTTP 状态码',
+ 'PluginForm.plugin.fault-injection.property.body': '响应体',
+ 'PluginForm.plugin.fault-injection.property.duration': '延迟时间(秒)',
+
+ 'PluginForm.plugin.http-logger.desc': 'http-logger 可以将日志数据请求推送到 HTTP/HTTPS 服务器',
+ 'PluginForm.plugin.http-logger.property.uri': '日志服务器地址',
+ 'PluginForm.plugin.http-logger.property.uri.extra': '比如 127.0.0.1:80/postendpoint?param=1',
+
+ 'PluginForm.plugin.ip-restriction.desc':
+ 'ip-restriction 可以把一批 IP 地址列入白名单或黑名单(二选一),时间复杂度是O(1),并支持用 CIDR 来表示 IP 范围',
+ 'PluginForm.plugin.ip-restriction.property.whitelist': '白名单',
+ 'PluginForm.plugin.ip-restriction.property.blacklist': '黑名单',
+
+ 'PluginForm.plugin.kafka-logger.desc': '把接口请求日志以 JSON 的形式推送给外部 Kafka 集群',
+ 'PluginForm.plugin.kafka-logger.property.broker_list': 'broker',
+ 'PluginForm.plugin.kafka-logger.property.kafka_topic': 'topic',
+
+ 'PluginForm.plugin.prometheus.desc': '提供符合 prometheus 数据格式的 metrics 数据',
+
+ 'PluginForm.plugin.proxy-cache.desc': '代理缓存插件,缓存后端服务的响应数据',
+ 'PluginForm.plugin.proxy-cache.property.cache_zone': '缓存区域名',
+ 'PluginForm.plugin.proxy-cache.property.cache_zone.extra': ' 本地目录为 /tmp/${区域名},修改默认区域名必须同时修改 config.yaml',
+ 'PluginForm.plugin.proxy-cache.property.cache_key': '缓存 key',
+ 'PluginForm.plugin.proxy-cache.property.cache_key.extra': '可以使用 Nginx 变量,例如:$host, $uri',
+ 'PluginForm.plugin.proxy-cache.property.cache_bypass': '跳过缓存检索',
+ 'PluginForm.plugin.proxy-cache.property.cache_bypass.extra': '这里可以使用 Nginx 变量,当此参数的值不为空或非0时将会跳过缓存的检索',
+ 'PluginForm.plugin.proxy-cache.property.cache_method': '缓存 Method',
+ 'PluginForm.plugin.proxy-cache.property.cache_http_status': '缓存响应状态码',
+ 'PluginForm.plugin.proxy-cache.property.hide_cache_headers': '隐藏缓存头',
+ 'PluginForm.plugin.proxy-cache.property.hide_cache_headers.extra': '是否将 Expires 和 Cache-Control 响应头返回给客户端',
+ 'PluginForm.plugin.proxy-cache.property.no_cache': '不缓存的数据',
+ 'PluginForm.plugin.proxy-cache.property.no_cache.extra': '这里可以使用 Nginx 变量, 当此参数的值不为空或非0时将不会缓存数据',
+
+ 'PluginForm.plugin.proxy-mirror.desc': 'proxy mirror 代理镜像插件,提供了镜像客户端请求的能力',
+ 'PluginForm.plugin.proxy-mirror.property.host': '镜像服务地址',
+ 'PluginForm.plugin.proxy-mirror.property.host.extra': '例如:http://127.0.0.1:9797。地址中需要包含 http 或 https,不能包含 URI 部分',
+
+ 'PluginForm.plugin.response-rewrite.desc': '该插件支持修改上游服务返回的 body 和 header 信息',
+ 'PluginForm.plugin.response-rewrite.property.status_code': '状态码',
+ 'PluginForm.plugin.response-rewrite.property.body': '响应体',
+ 'PluginForm.plugin.response-rewrite.property.body_base64': '响应体是否需要 base64 解码',
+ 'PluginForm.plugin.response-rewrite.property.headers': 'HTTP 头',
+
+ 'PluginForm.plugin.syslog.desc': '对接 syslog 日志服务器',
+ 'PluginForm.plugin.syslog.property.host': '日志服务器地址',
+ 'PluginForm.plugin.syslog.property.port': '日志服务器端口',
+ 'PluginForm.plugin.syslog.property.timeout': '超时时间',
+ 'PluginForm.plugin.syslog.property.tls': '开启 SSL',
+ 'PluginForm.plugin.syslog.property.flush_limit': '缓存区大小',
+ 'PluginForm.plugin.syslog.property.sock_type': '协议类型',
+ 'PluginForm.plugin.syslog.property.max_retry_times': '重试次数',
+ 'PluginForm.plugin.syslog.property.retry_interval': '重试间隔时间(毫秒)',
+ 'PluginForm.plugin.syslog.property.pool_size': '连接池大小',
+
+ 'PluginForm.plugin.tcp-logger.desc': '对接 TCP 日志服务器',
+ 'PluginForm.plugin.tcp-logger.property.host': '日志服务器地址',
+ 'PluginForm.plugin.tcp-logger.property.port': '日志服务器地址',
+ 'PluginForm.plugin.tcp-logger.property.timeout': '超时时间',
+ 'PluginForm.plugin.tcp-logger.property.tls': '开启 SSL',
+ 'PluginForm.plugin.tcp-logger.property.tls_options': 'TLS 选型',
+
+ 'PluginForm.plugin.udp-logger.desc': '对接 UDP 日志服务器',
+ 'PluginForm.plugin.udp-logger.property.host': '日志服务器地址',
+ 'PluginForm.plugin.udp-logger.property.port': '日志服务器地址',
+ 'PluginForm.plugin.udp-logger.property.timeout': '超时时间',
+
+ 'PluginForm.plugin.zipkin.desc': '对接 zipkin',
+ 'PluginForm.plugin.zipkin.property.endpoint': 'endpoint',
+ 'PluginForm.plugin.zipkin.property.endpoint.extra': '例如 http://127.0.0.1:9411/api/v2/spans',
+ 'PluginForm.plugin.zipkin.property.sample_ratio': '采样率',
+ 'PluginForm.plugin.zipkin.property.service_name': '服务名',
+ 'PluginForm.plugin.zipkin.property.server_addr': '网关实例 IP',
+ 'PluginForm.plugin.zipkin.property.server_addr.extra': '默认值是 Nginx 内置变量 server_addr',
+
+ 'PluginForm.plugin.skywalking.desc': '对接 Apache Skywalking',
+ 'PluginForm.plugin.skywalking.property.endpoint': 'endpoint',
+ 'PluginForm.plugin.skywalking.property.endpoint.extra': '例如 http://127.0.0.1:12800',
+ 'PluginForm.plugin.skywalking.property.sample_ratio': '采样率',
+ 'PluginForm.plugin.skywalking.property.service_name': '服务名',
+
+ 'PluginForm.plugin.serverless-pre-function.desc': '在指定阶段最开始的位置,运行指定的 Lua 函数',
+ 'PluginForm.plugin.serverless-pre-function.property.phase': '运行阶段',
+ 'PluginForm.plugin.serverless-pre-function.property.functions': '运行的函数集',
+
+ 'PluginForm.plugin.serverless-post-function.desc': '在指定阶段最后的位置,运行指定的 Lua 函数',
+ 'PluginForm.plugin.serverless-post-function.property.phase': '运行阶段',
+ 'PluginForm.plugin.serverless-post-function.property.functions': '运行的函数集',
+
+
+ 'PluginForm.plugin.basic-auth.desc': 'basic auth 插件',
+ 'PluginForm.plugin.jwt-auth.desc': 'JWT 认证插件',
+ 'PluginForm.plugin.key-auth.desc': 'key auth 插件',
+ 'PluginForm.plugin.wolf-rbac.desc': '对接 wolf RBAC 服务',
+ 'PluginForm.plugin.openid-connect.desc': 'Open ID Connect(OIDC) 插件提供对接外部认证服务的能力',
+
+ 'PluginForm.plugin.redirect.desc': '重定向插件',
+ 'PluginForm.plugin.proxy-rewrite.desc': 'proxy rewrite 代理改写插件,可以改写客户端请求',
+ 'PluginForm.plugin.mqtt-proxy.desc': 'mqtt-proxy 插件可以帮助你根据 MQTT 的 client_id 实现动态负载均衡',
+ 'PluginForm.plugin.grpc-transcoding.desc': 'gRPC 转换插件,实现 HTTP(s) -> APISIX -> gRPC server 的转换',
+ 'PluginForm.plugin.batch-requests.desc':
+ 'batch-requests 插件可以一次接受多个请求并以 http pipeline 的方式在网关发起多个 http 请求,合并结果后再返回客户端,这在客户端需要访问多个接口时可以显著地提升请求性能',
+};
diff --git a/src/components/PluginForm/service.ts b/src/components/PluginForm/service.ts
new file mode 100644
index 0000000..a688e9c
--- /dev/null
+++ b/src/components/PluginForm/service.ts
@@ -0,0 +1,5 @@
+import { request } from 'umi';
+import { transformSchemaFromAPI } from './transformer';
+
+export const fetchPluginSchema = (name: string): Promise<PluginForm.PluginSchema> =>
+ request(`/schema/plugins/${name}`).then((data) => transformSchemaFromAPI(data, name));
diff --git a/src/components/PluginForm/transformer.ts b/src/components/PluginForm/transformer.ts
new file mode 100644
index 0000000..dddbac5
--- /dev/null
+++ b/src/components/PluginForm/transformer.ts
@@ -0,0 +1,90 @@
+import { v4 as uuidv4 } from 'uuid';
+import { Rule } from 'antd/es/form';
+
+/**
+ * Transform schema data from API for target plugin.
+ */
+export const transformSchemaFromAPI = (
+ schema: PluginForm.PluginSchema,
+ pluginName: string,
+): PluginForm.PluginSchema => {
+ if (pluginName === 'key-auth') {
+ return {
+ ...schema,
+ properties: {
+ key: {
+ ...schema.properties!.key,
+ default: uuidv4(),
+ },
+ },
+ };
+ }
+
+ if (pluginName === 'prometheus' || pluginName === 'node-status' || pluginName === 'heartbeat') {
+ return {
+ ...schema,
+ properties: {
+ enabled: {
+ type: 'boolean',
+ default: false,
+ },
+ },
+ };
+ }
+
+ return schema;
+};
+
+/**
+ * Transform schema data to be compatible with API.
+ */
+// eslint-disable-next-line arrow-body-style
+export const transformSchemaToAPI = (schema: PluginForm.PluginSchema, pluginName: string) => {
+ return { schema, pluginName };
+};
+
+/**
+ * Transform schema's property to rules.
+ */
+export const transformPropertyToRules = (
+ schema: PluginForm.PluginSchema,
+ propertyName: string,
+ propertyValue: PluginForm.PluginProperty,
+): Rule[] => {
+ if (!schema) {
+ return [];
+ }
+
+ const { type, minLength, maxLength, minimum, maximum, pattern } = propertyValue;
+
+ const requiredRule = schema.required?.includes(propertyName) ? [{ required: true }] : [];
+ const typeRule = [{ type }];
+ const enumRule = propertyValue.enum ? [{ type: 'enum', enum: propertyValue.enum }] : [];
+ const rangeRule =
+ type !== 'string' &&
+ type !== 'array' &&
+ (propertyValue.hasOwnProperty('minimum') || propertyValue.hasOwnProperty('maximum'))
+ ? [
+ {
+ min: minimum ?? Number.MIN_SAFE_INTEGER,
+ max: maximum ?? Number.MAX_SAFE_INTEGER,
+ },
+ ]
+ : [];
+ const lengthRule =
+ type === 'string' || type === 'array'
+ ? [{ min: minLength ?? Number.MIN_SAFE_INTEGER, max: maxLength ?? Number.MAX_SAFE_INTEGER }]
+ : [];
+ const customPattern = pattern ? [{ pattern: new RegExp(pattern) }] : [];
+
+ const rules = [
+ ...requiredRule,
+ ...typeRule,
+ ...enumRule,
+ ...rangeRule,
+ ...lengthRule,
+ ...customPattern,
+ ];
+ const flattend = rules.reduce((prev, next) => ({ ...prev, ...next }));
+ return [flattend] as Rule[];
+};
diff --git a/src/components/PluginForm/typing.d.ts b/src/components/PluginForm/typing.d.ts
new file mode 100644
index 0000000..dbc106b
--- /dev/null
+++ b/src/components/PluginForm/typing.d.ts
@@ -0,0 +1,64 @@
+declare namespace PluginForm {
+ interface Props {
+ name?: string;
+ disabled?: boolean;
+ // FormInstance
+ form: any;
+ initialData?: PluginSchema;
+ onFinish(values: any): void;
+ }
+
+ interface PluginSchema {
+ type: 'object';
+ id?: string;
+ required?: string[];
+ additionalProperties?: boolean;
+ minProperties?: number;
+ oneOf?: Array<{
+ required: string[];
+ }>;
+ properties?: {
+ [propertyName: string]: PluginProperty;
+ };
+ }
+
+ interface PluginProperty {
+ type: 'number' | 'string' | 'integer' | 'array' | 'boolean' | 'object';
+ // the same as type
+ default?: any;
+ description?: string;
+ // NOTE: maybe 0.00001
+ minimum?: number;
+ maximum?: number;
+ minLength?: number;
+ maxLength?: number;
+ minItems?: number;
+ maxItems?: number;
+ // e.g "^/.*"
+ pattern?: string;
+ enum?: string[];
+ requried?: string[];
+ minProperties?: number;
+ additionalProperties?: boolean;
+ items?: {
+ type: string;
+ anyOf?: Array<{
+ type?: string;
+ description?: string;
+ enum?: string[];
+ pattern?: string;
+ }>;
+ };
+ }
+
+ type PluginCategory = 'Security' | 'Limit' | 'Log' | 'Metric' | 'Other';
+
+ type PluginMapperItem = {
+ category: PluginCategory;
+ hidden?: boolean;
+ };
+
+ interface PluginProps extends PluginMapperItem {
+ name: string;
+ }
+}
diff --git a/src/components/PluginModal/index.tsx b/src/components/PluginModal/index.tsx
new file mode 100644
index 0000000..9f4882c
--- /dev/null
+++ b/src/components/PluginModal/index.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import { Modal } from 'antd';
+import { useIntl } from 'umi';
+
+import PluginForm from '@/components/PluginForm';
+import { useForm } from 'antd/es/form/util';
+
+interface Props {
+ visible: boolean;
+ name: string;
+ initialData?: PluginForm.PluginSchema;
+ onFinish(values: any): void;
+}
+
+const PluginModal: React.FC<Props> = (props) => {
+ const { name, visible } = props;
+ const [form] = useForm();
+
+ return (
+ <Modal
+ destroyOnClose
+ visible={visible}
+ title={`${useIntl().formatMessage({ id: 'component.global.edit.plugin' })} ${name}`}
+ okText="确定"
+ cancelText="取消"
+ >
+ <PluginForm form={form} {...props} />
+ </Modal>
+ );
+};
+
+export default PluginModal;
diff --git a/src/components/RightContent/AvatarDropdown.tsx b/src/components/RightContent/AvatarDropdown.tsx
new file mode 100644
index 0000000..567f2ae
--- /dev/null
+++ b/src/components/RightContent/AvatarDropdown.tsx
@@ -0,0 +1,94 @@
+import React, { useCallback } from 'react';
+import { SettingOutlined, UserOutlined, SettingFilled } from '@ant-design/icons';
+import { Avatar, Menu, Spin } from 'antd';
+import { ClickParam } from 'antd/es/menu';
+import { history, useModel } from 'umi';
+
+import { stringify } from 'querystring';
+import HeaderDropdown from '../HeaderDropdown';
+import styles from './index.less';
+
+export interface GlobalHeaderRightProps {
+ menu?: boolean;
+}
+
+/**
+ * 退出登录,并且将当前的 url 保存
+ */
+const settings = async () => {
+ history.replace({
+ pathname: '/setting',
+ search: stringify({
+ redirect: window.location.href,
+ }),
+ });
+};
+
+const AvatarDropdown: React.FC<GlobalHeaderRightProps> = ({ menu }) => {
+ const { initialState, setInitialState } = useModel('@@initialState');
+
+ const onMenuClick = useCallback((event: ClickParam) => {
+ const { key } = event;
+ if (key === 'settings') {
+ setInitialState({ ...initialState, currentUser: undefined });
+ settings();
+ return;
+ }
+ history.push(`/account/${key}`);
+ }, []);
+
+ const loading = (
+ <span className={`${styles.action} ${styles.account}`}>
+ <Spin
+ size="small"
+ style={{
+ marginLeft: 8,
+ marginRight: 8,
+ }}
+ />
+ </span>
+ );
+
+ if (!initialState) {
+ return loading;
+ }
+
+ const { currentUser } = initialState;
+
+ if (!currentUser || !currentUser.name) {
+ return loading;
+ }
+
+ const menuHeaderDropdown = (
+ <Menu className={styles.menu} selectedKeys={[]} onClick={onMenuClick}>
+ {menu && (
+ <Menu.Item key="center">
+ <UserOutlined />
+ 个人中心
+ </Menu.Item>
+ )}
+ {menu && (
+ <Menu.Item key="settings">
+ <SettingOutlined />
+ 个人设置
+ </Menu.Item>
+ )}
+ {menu && <Menu.Divider />}
+
+ <Menu.Item key="settings">
+ <SettingFilled />
+ 修改设置
+ </Menu.Item>
+ </Menu>
+ );
+ return (
+ <HeaderDropdown overlay={menuHeaderDropdown}>
+ <span className={`${styles.action} ${styles.account}`}>
+ <Avatar size="small" className={styles.avatar} src={currentUser.avatar} alt="avatar" />
+ <span className={`${styles.name} anticon`}>{currentUser.name}</span>
+ </span>
+ </HeaderDropdown>
+ );
+};
+
+export default AvatarDropdown;
diff --git a/src/components/RightContent/index.less b/src/components/RightContent/index.less
new file mode 100644
index 0000000..ef2549e
--- /dev/null
+++ b/src/components/RightContent/index.less
@@ -0,0 +1,82 @@
+@import '~antd/es/style/themes/default.less';
+
+@pro-header-hover-bg: rgba(0, 0, 0, 0.025);
+
+.menu {
+ :global(.anticon) {
+ margin-right: 8px;
+ }
+ :global(.ant-dropdown-menu-item) {
+ min-width: 160px;
+ }
+}
+
+.right {
+ display: flex;
+ float: right;
+ height: 48px;
+ margin-left: auto;
+ overflow: hidden;
+ .action {
+ display: flex;
+ align-items: center;
+ height: 48px;
+ padding: 0 12px;
+ cursor: pointer;
+ transition: all 0.3s;
+
+ &:hover {
+ background: @pro-header-hover-bg;
+ }
+ &:global(.opened) {
+ background: @pro-header-hover-bg;
+ }
+ }
+ .search {
+ padding: 0 12px;
+ &:hover {
+ background: transparent;
+ }
+ }
+ .account {
+ .avatar {
+ margin-right: 8px;
+ color: @primary-color;
+ vertical-align: top;
+ background: rgba(255, 255, 255, 0.85);
+ }
+ }
+}
+
+.dark {
+ .action {
+ &:hover {
+ background: #252a3d;
+ }
+ &:global(.opened) {
+ background: #252a3d;
+ }
+ }
+}
+
+@media only screen and (max-width: @screen-md) {
+ :global(.ant-divider-vertical) {
+ vertical-align: unset;
+ }
+ .name {
+ display: none;
+ }
+ .right {
+ position: absolute;
+ top: 0;
+ right: 12px;
+ .account {
+ .avatar {
+ margin-right: 0;
+ }
+ }
+ .search {
+ display: none;
+ }
+ }
+}
diff --git a/src/components/RightContent/index.tsx b/src/components/RightContent/index.tsx
new file mode 100644
index 0000000..f54e5b7
--- /dev/null
+++ b/src/components/RightContent/index.tsx
@@ -0,0 +1,51 @@
+import { Tooltip, Tag, Space } from 'antd';
+import { QuestionCircleOutlined } from '@ant-design/icons';
+import React from 'react';
+import { useModel, SelectLang } from 'umi';
+import Avatar from './AvatarDropdown';
+import styles from './index.less';
+
+export type SiderTheme = 'light' | 'dark';
+
+const ENVTagColor = {
+ dev: 'orange',
+ test: 'green',
+ pre: '#87d068',
+};
+
+const GlobalHeaderRight: React.FC<{}> = () => {
+ const { initialState } = useModel('@@initialState');
+
+ if (!initialState || !initialState.settings) {
+ return null;
+ }
+
+ const { navTheme, layout } = initialState.settings;
+ let className = styles.right;
+
+ if ((navTheme === 'dark' && layout === 'top') || layout === 'mix') {
+ className = `${styles.right} ${styles.dark}`;
+ }
+ return (
+ <Space className={className}>
+ <Tooltip title="Documentation">
+ <span
+ className={styles.action}
+ onClick={() => {
+ window.location.href = 'https://github.com/apache/incubator-apisix';
+ }}
+ >
+ <QuestionCircleOutlined />
+ </span>
+ </Tooltip>
+ <Avatar />
+ {REACT_APP_ENV && (
+ <span>
+ <Tag color={ENVTagColor[REACT_APP_ENV]}>{REACT_APP_ENV}</Tag>
+ </span>
+ )}
+ <SelectLang className={styles.action} />
+ </Space>
+ );
+};
+export default GlobalHeaderRight;
diff --git a/src/e2e/__mocks__/antd-pro-merge-less.js b/src/e2e/__mocks__/antd-pro-merge-less.js
new file mode 100644
index 0000000..f237ddf
--- /dev/null
+++ b/src/e2e/__mocks__/antd-pro-merge-less.js
@@ -0,0 +1 @@
+export default undefined;
diff --git a/src/e2e/baseLayout.e2e.js b/src/e2e/baseLayout.e2e.js
new file mode 100644
index 0000000..f328ada
--- /dev/null
+++ b/src/e2e/baseLayout.e2e.js
@@ -0,0 +1,57 @@
+const { uniq } = require('lodash');
+const RouterConfig = require('../../config/config').default.routes;
+
+const BASE_URL = `http://localhost:${process.env.PORT || 8000}`;
+
+function formatter(routes, parentPath = '') {
+ const fixedParentPath = parentPath.replace(/\/{1,}/g, '/');
+ let result = [];
+ routes.forEach((item) => {
+ if (item.path) {
+ result.push(`${fixedParentPath}/${item.path}`.replace(/\/{1,}/g, '/'));
+ }
+ if (item.routes) {
+ result = result.concat(
+ formatter(item.routes, item.path ? `${fixedParentPath}/${item.path}` : parentPath),
+ );
+ }
+ });
+ return uniq(result.filter((item) => !!item));
+}
+
+beforeEach(async () => {
+ await page.goto(`${BASE_URL}`);
+ await page.evaluate(() => {
+ localStorage.setItem('antd-pro-authority', '["admin"]');
+ });
+});
+
+describe('Ant Design Pro E2E test', () => {
+ const testPage = (path) => async () => {
+ await page.goto(`${BASE_URL}${path}`);
+ await page.waitForSelector('footer', {
+ timeout: 2000,
+ });
+ const haveFooter = await page.evaluate(
+ () => document.getElementsByTagName('footer').length > 0,
+ );
+ expect(haveFooter).toBeTruthy();
+ };
+
+ const routers = formatter(RouterConfig);
+ routers.forEach((route) => {
+ it(`test pages ${route}`, testPage(route));
+ });
+
+ it('topmenu should have footer', async () => {
+ const params = '?navTheme=light&layout=topmenu';
+ await page.goto(`${BASE_URL}${params}`);
+ await page.waitForSelector('footer', {
+ timeout: 2000,
+ });
+ const haveFooter = await page.evaluate(
+ () => document.getElementsByTagName('footer').length > 0,
+ );
+ expect(haveFooter).toBeTruthy();
+ });
+});
diff --git a/src/global.less b/src/global.less
new file mode 100644
index 0000000..f38a267
--- /dev/null
+++ b/src/global.less
@@ -0,0 +1,69 @@
+@import '~antd/es/style/themes/default.less';
+
+html,
+body,
+#root {
+ height: 100%;
+}
+
+.colorWeak {
+ filter: invert(80%);
+}
+
+.ant-layout {
+ min-height: 100vh;
+}
+
+canvas {
+ display: block;
+}
+
+body {
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+ul,
+ol {
+ list-style: none;
+}
+
+@media (max-width: @screen-xs) {
+ .ant-table {
+ width: 100%;
+ overflow-x: auto;
+ &-thead > tr,
+ &-tbody > tr {
+ > th,
+ > td {
+ white-space: pre;
+ > span {
+ display: block;
+ }
+ }
+ }
+ }
+}
+
+// 兼容IE11
+@media screen and(-ms-high-contrast: active), (-ms-high-contrast: none) {
+ body .ant-design-pro > .ant-layout {
+ min-height: 100vh;
+ }
+}
+
+.ant-pro-sider-light {
+ z-index: 99;
+}
+
+.ant-checkbox-disabled > .ant-checkbox-inner,
+.ant-radio-disabled > .ant-radio-inner,
+.ant-input-number-disabled,
+.ant-input[disabled] {
+ background-color: #fff !important;
+}
+
+.ant-select-disabled.ant-select-single:not(.ant-select-customize-input) .ant-select-selector {
+ background-color: #fff !important;
+}
diff --git a/src/global.tsx b/src/global.tsx
new file mode 100644
index 0000000..f7620c4
--- /dev/null
+++ b/src/global.tsx
@@ -0,0 +1,83 @@
+import { Button, message, notification } from 'antd';
+
+import React from 'react';
+import { formatMessage } from 'umi';
+import defaultSettings from '../config/defaultSettings';
+
+const { pwa } = defaultSettings;
+// if pwa is true
+if (pwa) {
+ // Notify user if offline now
+ window.addEventListener('sw.offline', () => {
+ message.warning(formatMessage({ id: 'app.pwa.offline' }));
+ });
+
+ // Pop up a prompt on the page asking the user if they want to use the latest version
+ window.addEventListener('sw.updated', (event: Event) => {
+ const e = event as CustomEvent;
+ const reloadSW = async () => {
+ // Check if there is sw whose state is waiting in ServiceWorkerRegistration
+ // https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration
+ const worker = e.detail && e.detail.waiting;
+ if (!worker) {
+ return true;
+ }
+ // Send skip-waiting event to waiting SW with MessageChannel
+ await new Promise((resolve, reject) => {
+ const channel = new MessageChannel();
+ channel.port1.onmessage = (msgEvent) => {
+ if (msgEvent.data.error) {
+ reject(msgEvent.data.error);
+ } else {
+ resolve(msgEvent.data);
+ }
+ };
+ worker.postMessage({ type: 'skip-waiting' }, [channel.port2]);
+ });
+ // Refresh current page to use the updated HTML and other assets after SW has skiped waiting
+ window.location.reload(true);
+ return true;
+ };
+ const key = `open${Date.now()}`;
+ const btn = (
+ <Button
+ type="primary"
+ onClick={() => {
+ notification.close(key);
+ reloadSW();
+ }}
+ >
+ {formatMessage({ id: 'app.pwa.serviceworker.updated.ok' })}
+ </Button>
+ );
+ notification.open({
+ message: formatMessage({ id: 'app.pwa.serviceworker.updated' }),
+ description: formatMessage({ id: 'app.pwa.serviceworker.updated.hint' }),
+ btn,
+ key,
+ onClose: async () => {},
+ });
+ });
+} else if ('serviceWorker' in navigator) {
+ // unregister service worker
+ const { serviceWorker } = navigator;
+ if (serviceWorker.getRegistrations) {
+ serviceWorker.getRegistrations().then((sws) => {
+ sws.forEach((sw) => {
+ sw.unregister();
+ });
+ });
+ }
+ serviceWorker.getRegistration().then((sw) => {
+ if (sw) sw.unregister();
+ });
+
+ // remove all caches
+ if (window.caches && window.caches.keys) {
+ caches.keys().then((keys) => {
+ keys.forEach((key) => {
+ caches.delete(key);
+ });
+ });
+ }
+}
diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts
new file mode 100644
index 0000000..6b136f7
--- /dev/null
+++ b/src/locales/en-US.ts
@@ -0,0 +1,23 @@
+import { PluginFormEnUS } from '@/components/PluginForm';
+
+import component from './en-US/component';
+import globalHeader from './en-US/globalHeader';
+import menu from './en-US/menu';
+import pwa from './en-US/pwa';
+import settingDrawer from './en-US/settingDrawer';
+import settings from './en-US/setting';
+
+export default {
+ 'navBar.lang': 'Languages',
+ 'layout.user.link.help': 'Help',
+ 'layout.user.link.privacy': 'Privacy',
+ 'layout.user.link.terms': 'Terms',
+ 'app.preview.down.block': 'Download this page to your local project',
+ ...globalHeader,
+ ...menu,
+ ...settingDrawer,
+ ...settings,
+ ...pwa,
+ ...component,
+ ...PluginFormEnUS,
+};
diff --git a/src/locales/en-US/component.ts b/src/locales/en-US/component.ts
new file mode 100644
index 0000000..9a7fc5d
--- /dev/null
+++ b/src/locales/en-US/component.ts
@@ -0,0 +1,39 @@
+export default {
+ 'component.tagSelect.expand': 'Expand',
+ 'component.tagSelect.collapse': 'Collapse',
+ 'component.tagSelect.all': 'All',
+ 'component.global.remove': 'Remove',
+ 'component.global.cancel': 'Cancel',
+ 'component.global.submit': 'Submit',
+ 'component.global.create': 'Create',
+ 'component.global.add': 'Add',
+ 'component.global.save': 'Save',
+ 'component.global.edit': 'Edit',
+ 'component.global.action': 'Action',
+ 'component.global.update': 'Update',
+ 'component.global.get': 'Get',
+ 'component.global.edit.plugin': 'Edit plugin',
+ 'component.global.loading': 'Loading',
+ 'component.status.success': 'Successfully',
+ 'component.status.fail': 'Failed',
+ // User component
+ 'component.user.loginByPassword': 'Username & Password',
+ 'component.user.login': 'Login',
+ 'component.user.username': 'Username',
+ 'component.user.password': 'Password',
+ 'component.user.rememberMe': 'Remember Me',
+ 'component.user.inputUsername': 'Please input username!',
+ 'component.user.inputPassword': 'Please input password!',
+ 'component.user.wrongUsernameOrPassword': 'Wrong account or password!',
+ // SSL Module
+ 'component.ssl.removeSSLItemModalContent': 'You are going to remove this item!',
+ 'component.ssl.removeSSLItemModalTitle': 'SSL Remove Alert',
+ 'component.ssl.fetchSSLListSuccess': 'Fetch SSL list successfully',
+ 'component.ssl.removeSSLSuccess': 'Remove target SSL successfully',
+ 'component.ssl.fieldSNIInvalid': 'Please check SNI',
+ 'component.ssl.fieldKeyInvalid': 'Please check Key',
+ 'component.ssl.fieldCertInvalid': 'Please check Cert',
+ 'component.ssl.invalidKey': 'Invalid Key',
+ 'component.ssl.fieldKeyTooShort': 'Key is too short, 128 characters at least.',
+ 'component.ssl.fieldCertTooShort': 'Cert is too short, 128 characters at least.',
+};
diff --git a/src/locales/en-US/globalHeader.ts b/src/locales/en-US/globalHeader.ts
new file mode 100644
index 0000000..60b6d4e
--- /dev/null
+++ b/src/locales/en-US/globalHeader.ts
@@ -0,0 +1,17 @@
+export default {
+ 'component.globalHeader.search': 'Search',
+ 'component.globalHeader.search.example1': 'Search example 1',
+ 'component.globalHeader.search.example2': 'Search example 2',
+ 'component.globalHeader.search.example3': 'Search example 3',
+ 'component.globalHeader.help': 'Help',
+ 'component.globalHeader.notification': 'Notification',
+ 'component.globalHeader.notification.empty': 'You have viewed all notifications.',
+ 'component.globalHeader.message': 'Message',
+ 'component.globalHeader.message.empty': 'You have viewed all messsages.',
+ 'component.globalHeader.event': 'Event',
+ 'component.globalHeader.event.empty': 'You have viewed all events.',
+ 'component.noticeIcon.clear': 'Clear',
+ 'component.noticeIcon.cleared': 'Cleared',
+ 'component.noticeIcon.empty': 'No notifications',
+ 'component.noticeIcon.view-more': 'View more',
+};
diff --git a/src/locales/en-US/menu.ts b/src/locales/en-US/menu.ts
new file mode 100644
index 0000000..90b066f
--- /dev/null
+++ b/src/locales/en-US/menu.ts
@@ -0,0 +1,60 @@
+export default {
+ 'menu.more-blocks': 'More Blocks',
+ 'menu.home': 'Home',
+ 'menu.admin': 'Admin',
+ 'menu.admin.sub-page': 'Sub-Page',
+ 'menu.login': 'Login',
+ 'menu.register': 'Register',
+ 'menu.register.result': 'Register Result',
+ 'menu.dashboard': 'Dashboard',
+ 'menu.dashboard.analysis': 'Analysis',
+ 'menu.dashboard.monitor': 'Monitor',
+ 'menu.dashboard.workplace': 'Workplace',
+ 'menu.exception.403': '403',
+ 'menu.exception.404': '404',
+ 'menu.exception.500': '500',
+ 'menu.form': 'Form',
+ 'menu.form.basic-form': 'Basic Form',
+ 'menu.form.step-form': 'Step Form',
+ 'menu.form.step-form.info': 'Step Form(write transfer information)',
+ 'menu.form.step-form.confirm': 'Step Form(confirm transfer information)',
+ 'menu.form.step-form.result': 'Step Form(finished)',
+ 'menu.form.advanced-form': 'Advanced Form',
+ 'menu.list': 'List',
+ 'menu.list.table-list': 'Search Table',
+ 'menu.list.basic-list': 'Basic List',
+ 'menu.list.card-list': 'Card List',
+ 'menu.list.search-list': 'Search List',
+ 'menu.list.search-list.articles': 'Search List(articles)',
+ 'menu.list.search-list.projects': 'Search List(projects)',
+ 'menu.list.search-list.applications': 'Search List(applications)',
+ 'menu.profile': 'Profile',
+ 'menu.profile.basic': 'Basic Profile',
+ 'menu.profile.advanced': 'Advanced Profile',
+ 'menu.result': 'Result',
+ 'menu.result.success': 'Success',
+ 'menu.result.fail': 'Fail',
+ 'menu.exception': 'Exception',
+ 'menu.exception.not-permission': '403',
+ 'menu.exception.not-find': '404',
+ 'menu.exception.server-error': '500',
+ 'menu.exception.trigger': 'Trigger',
+ 'menu.account': 'Account',
+ 'menu.account.center': 'Account Center',
+ 'menu.account.settings': 'Account Settings',
+ 'menu.account.trigger': 'Trigger Error',
+ 'menu.account.logout': 'Logout',
+ 'menu.editor': 'Graphic Editor',
+ 'menu.editor.flow': 'Flow Editor',
+ 'menu.editor.mind': 'Mind Editor',
+ 'menu.editor.koni': 'Koni Editor',
+ 'menu.ssl': 'SSL',
+ 'menu.ssl.list': 'SSL List',
+ 'menu.ssl.edit': 'Edit',
+ 'menu.ssl.create': 'Create',
+ 'menu.setting': 'Settings',
+ 'menu.routes': 'Route',
+ 'menu.routes.list': 'Route List',
+ 'menu.routes.create': 'Create a Route',
+ 'menu.routes.edit': 'Edit the Route',
+};
diff --git a/src/locales/en-US/pwa.ts b/src/locales/en-US/pwa.ts
new file mode 100644
index 0000000..ed8d199
--- /dev/null
+++ b/src/locales/en-US/pwa.ts
@@ -0,0 +1,6 @@
+export default {
+ 'app.pwa.offline': 'You are offline now',
+ 'app.pwa.serviceworker.updated': 'New content is available',
+ 'app.pwa.serviceworker.updated.hint': 'Please press the "Refresh" button to reload current page',
+ 'app.pwa.serviceworker.updated.ok': 'Refresh',
+};
diff --git a/src/locales/en-US/setting.ts b/src/locales/en-US/setting.ts
new file mode 100644
index 0000000..b6ce2cf
--- /dev/null
+++ b/src/locales/en-US/setting.ts
@@ -0,0 +1,4 @@
+export default {
+ 'app.settings.admin-api': 'Admin API Config',
+ 'app.settings.item.baseURL': 'API base URL',
+};
diff --git a/src/locales/en-US/settingDrawer.ts b/src/locales/en-US/settingDrawer.ts
new file mode 100644
index 0000000..a644905
--- /dev/null
+++ b/src/locales/en-US/settingDrawer.ts
@@ -0,0 +1,31 @@
+export default {
+ 'app.setting.pagestyle': 'Page style setting',
+ 'app.setting.pagestyle.dark': 'Dark style',
+ 'app.setting.pagestyle.light': 'Light style',
+ 'app.setting.content-width': 'Content Width',
+ 'app.setting.content-width.fixed': 'Fixed',
+ 'app.setting.content-width.fluid': 'Fluid',
+ 'app.setting.themecolor': 'Theme Color',
+ 'app.setting.themecolor.dust': 'Dust Red',
+ 'app.setting.themecolor.volcano': 'Volcano',
+ 'app.setting.themecolor.sunset': 'Sunset Orange',
+ 'app.setting.themecolor.cyan': 'Cyan',
+ 'app.setting.themecolor.green': 'Polar Green',
+ 'app.setting.themecolor.daybreak': 'Daybreak Blue (default)',
+ 'app.setting.themecolor.geekblue': 'Geek Glue',
+ 'app.setting.themecolor.purple': 'Golden Purple',
+ 'app.setting.navigationmode': 'Navigation Mode',
+ 'app.setting.sidemenu': 'Side Menu Layout',
+ 'app.setting.topmenu': 'Top Menu Layout',
+ 'app.setting.fixedheader': 'Fixed Header',
+ 'app.setting.fixedsidebar': 'Fixed Sidebar',
+ 'app.setting.fixedsidebar.hint': 'Works on Side Menu Layout',
+ 'app.setting.hideheader': 'Hidden Header when scrolling',
+ 'app.setting.hideheader.hint': 'Works when Hidden Header is enabled',
+ 'app.setting.othersettings': 'Other Settings',
+ 'app.setting.weakmode': 'Weak Mode',
+ 'app.setting.copy': 'Copy Setting',
+ 'app.setting.copyinfo': 'copy success,please replace defaultSettings in src/models/setting.js',
+ 'app.setting.production.hint':
+ 'Setting panel shows in development environment only, please manually modify',
+};
diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts
new file mode 100644
index 0000000..67b4086
--- /dev/null
+++ b/src/locales/zh-CN.ts
@@ -0,0 +1,23 @@
+import { PluginFormZhCN } from '@/components/PluginForm';
+
+import component from './zh-CN/component';
+import globalHeader from './zh-CN/globalHeader';
+import menu from './zh-CN/menu';
+import pwa from './zh-CN/pwa';
+import settingDrawer from './zh-CN/settingDrawer';
+import settings from './zh-CN/setting';
+
+export default {
+ 'navBar.lang': '语言',
+ 'layout.user.link.help': '帮助',
+ 'layout.user.link.privacy': '隐私',
+ 'layout.user.link.terms': '条款',
+ 'app.preview.down.block': '下载此页面到本地项目',
+ ...globalHeader,
+ ...menu,
+ ...settingDrawer,
+ ...settings,
+ ...pwa,
+ ...component,
+ ...PluginFormZhCN,
+};
diff --git a/src/locales/zh-CN/component.ts b/src/locales/zh-CN/component.ts
new file mode 100644
index 0000000..048190e
--- /dev/null
+++ b/src/locales/zh-CN/component.ts
@@ -0,0 +1,39 @@
+export default {
+ 'component.tagSelect.expand': '展开',
+ 'component.tagSelect.collapse': '收起',
+ 'component.tagSelect.all': '全部',
+ 'component.global.remove': '删除',
+ 'component.global.cancel': '取消',
+ 'component.global.submit': '提交',
+ 'component.global.create': '创建',
+ 'component.global.add': '新建',
+ 'component.global.save': '保存',
+ 'component.global.edit': '编辑',
+ 'component.global.action': '操作',
+ 'component.global.update': '更新',
+ 'component.global.get': '获取',
+ 'component.global.edit.plugin': '编辑插件',
+ 'component.global.loading': '加载中',
+ 'component.status.success': '成功',
+ 'component.status.fail': '失败',
+ // User component
+ 'component.user.loginByPassword': '账号密码登录',
+ 'component.user.login': '登录',
+ 'component.user.username': '账号',
+ 'component.user.password': '密码',
+ 'component.user.rememberMe': '自动登录',
+ 'component.user.inputUsername': '请输入账号!',
+ 'component.user.inputPassword': '请输入密码!',
+ 'component.user.wrongUsernameOrPassword': '账号或密码错误!',
+ // SSL Module
+ 'component.ssl.removeSSLItemModalContent': '确定要删除该项吗?',
+ 'component.ssl.removeSSLItemModalTitle': '删除 SSL',
+ 'component.ssl.fetchSSLListSuccess': '获取 SSL 列表成功',
+ 'component.ssl.removeSSLSuccess': '删除 SSL 成功',
+ 'component.ssl.fieldSNIInvalid': '请检查 SNI 值',
+ 'component.ssl.fieldKeyInvalid': '请检查 Key 值',
+ 'component.ssl.fieldCertInvalid': '请检查 Cert 值',
+ 'component.ssl.invalidKey': '非法的 Key',
+ 'component.ssl.fieldKeyTooShort': 'Key 值过短,至少需要128位!',
+ 'component.ssl.fieldCertTooShort': 'Cert 值过短,至少需要128位!',
+};
diff --git a/src/locales/zh-CN/globalHeader.ts b/src/locales/zh-CN/globalHeader.ts
new file mode 100644
index 0000000..9fd66a5
--- /dev/null
+++ b/src/locales/zh-CN/globalHeader.ts
@@ -0,0 +1,17 @@
+export default {
+ 'component.globalHeader.search': '站内搜索',
+ 'component.globalHeader.search.example1': '搜索提示一',
+ 'component.globalHeader.search.example2': '搜索提示二',
+ 'component.globalHeader.search.example3': '搜索提示三',
+ 'component.globalHeader.help': '使用文档',
+ 'component.globalHeader.notification': '通知',
+ 'component.globalHeader.notification.empty': '你已查看所有通知',
+ 'component.globalHeader.message': '消息',
+ 'component.globalHeader.message.empty': '您已读完所有消息',
+ 'component.globalHeader.event': '待办',
+ 'component.globalHeader.event.empty': '你已完成所有待办',
+ 'component.noticeIcon.clear': '清空',
+ 'component.noticeIcon.cleared': '清空了',
+ 'component.noticeIcon.empty': '暂无数据',
+ 'component.noticeIcon.view-more': '查看更多',
+};
diff --git a/src/locales/zh-CN/menu.ts b/src/locales/zh-CN/menu.ts
new file mode 100644
index 0000000..6821b81
--- /dev/null
+++ b/src/locales/zh-CN/menu.ts
@@ -0,0 +1,61 @@
+export default {
+ 'menu.more-blocks': '更多区块',
+ 'menu.home': '首页',
+ 'menu.admin': '管理页',
+ 'menu.admin.sub-page': '二级管理页',
+ 'menu.login': '登录',
+ 'menu.register': '注册',
+ 'menu.register.result': '注册结果',
+ 'menu.dashboard': 'Dashboard',
+ 'menu.dashboard.analysis': '分析页',
+ 'menu.dashboard.monitor': '监控页',
+ 'menu.dashboard.workplace': '工作台',
+ 'menu.exception.403': '403',
+ 'menu.exception.404': '404',
+ 'menu.exception.500': '500',
+ 'menu.form': '表单页',
+ 'menu.form.basic-form': '基础表单',
+ 'menu.form.step-form': '分步表单',
+ 'menu.form.step-form.info': '分步表单(填写转账信息)',
+ 'menu.form.step-form.confirm': '分步表单(确认转账信息)',
+ 'menu.form.step-form.result': '分步表单(完成)',
+ 'menu.form.advanced-form': '高级表单',
+ 'menu.list': '列表页',
+ 'menu.list.table-list': '查询表格',
+ 'menu.list.basic-list': '标准列表',
+ 'menu.list.card-list': '卡片列表',
+ 'menu.list.search-list': '搜索列表',
+ 'menu.list.search-list.articles': '搜索列表(文章)',
+ 'menu.list.search-list.projects': '搜索列表(项目)',
+ 'menu.list.search-list.applications': '搜索列表(应用)',
+ 'menu.profile': '详情页',
+ 'menu.profile.basic': '基础详情页',
+ 'menu.profile.advanced': '高级详情页',
+ 'menu.result': '结果页',
+ 'menu.result.success': '成功页',
+ 'menu.result.fail': '失败页',
+ 'menu.exception': '异常页',
+ 'menu.exception.not-permission': '403',
+ 'menu.exception.not-find': '404',
+ 'menu.exception.server-error': '500',
+ 'menu.exception.trigger': '触发错误',
+ 'menu.account': '个人页',
+ 'menu.account.center': '个人中心',
+ 'menu.account.settings': '个人设置',
+ 'menu.account.trigger': '触发报错',
+ 'menu.account.logout': '退出登录',
+ 'menu.editor': '图形编辑器',
+ 'menu.editor.flow': '流程编辑器',
+ 'menu.editor.mind': '脑图编辑器',
+ 'menu.editor.koni': '拓扑编辑器',
+ 'menu.ssl': 'SSL',
+ 'menu.ssl.list': 'SSL 列表',
+ 'menu.ssl.edit': '编辑',
+ 'menu.ssl.create': '创建',
+ 'menu.setting': '设置',
+ 'menu.metrics': '监控',
+ 'menu.routes': '路由',
+ 'menu.routes.list': '列表',
+ 'menu.routes.create': '创建',
+ 'menu.routes.edit': '编辑',
+};
diff --git a/src/locales/zh-CN/pwa.ts b/src/locales/zh-CN/pwa.ts
new file mode 100644
index 0000000..e950484
--- /dev/null
+++ b/src/locales/zh-CN/pwa.ts
@@ -0,0 +1,6 @@
+export default {
+ 'app.pwa.offline': '当前处于离线状态',
+ 'app.pwa.serviceworker.updated': '有新内容',
+ 'app.pwa.serviceworker.updated.hint': '请点击“刷新”按钮或者手动刷新页面',
+ 'app.pwa.serviceworker.updated.ok': '刷新',
+};
diff --git a/src/locales/zh-CN/setting.ts b/src/locales/zh-CN/setting.ts
new file mode 100644
index 0000000..43f198e
--- /dev/null
+++ b/src/locales/zh-CN/setting.ts
@@ -0,0 +1,12 @@
+export default {
+ 'app.settings.admin-api': '管理 API 配置',
+ 'app.settings.item.baseURL': 'API 基础地址',
+ 'app.settings.item.admin-api-schema': '管理 API 协议',
+ 'app.settings.item.admin-api-host': '管理 API 地址',
+ 'app.settings.item.admin-api-path': '管理 API 路径',
+ 'app.settings.item.admin-api-key': '管理 API 密钥',
+ 'app.settings.item.admin-api-grafana': 'Grafana 地址',
+ 'app.settings.description.invalid-admin-api-schema': '非法的管理 API 协议',
+ 'app.settings.description.invalid-admin-api-host': '非法的管理 API 地址',
+ 'app.settings.description.invalid-admin-api-path': '非法的管理 API 路径',
+};
diff --git a/src/locales/zh-CN/settingDrawer.ts b/src/locales/zh-CN/settingDrawer.ts
new file mode 100644
index 0000000..15685a4
--- /dev/null
+++ b/src/locales/zh-CN/settingDrawer.ts
@@ -0,0 +1,31 @@
+export default {
+ 'app.setting.pagestyle': '整体风格设置',
+ 'app.setting.pagestyle.dark': '暗色菜单风格',
+ 'app.setting.pagestyle.light': '亮色菜单风格',
+ 'app.setting.content-width': '内容区域宽度',
+ 'app.setting.content-width.fixed': '定宽',
+ 'app.setting.content-width.fluid': '流式',
+ 'app.setting.themecolor': '主题色',
+ 'app.setting.themecolor.dust': '薄暮',
+ 'app.setting.themecolor.volcano': '火山',
+ 'app.setting.themecolor.sunset': '日暮',
+ 'app.setting.themecolor.cyan': '明青',
+ 'app.setting.themecolor.green': '极光绿',
+ 'app.setting.themecolor.daybreak': '拂晓蓝(默认)',
+ 'app.setting.themecolor.geekblue': '极客蓝',
+ 'app.setting.themecolor.purple': '酱紫',
+ 'app.setting.navigationmode': '导航模式',
+ 'app.setting.sidemenu': '侧边菜单布局',
+ 'app.setting.topmenu': '顶部菜单布局',
+ 'app.setting.fixedheader': '固定 Header',
+ 'app.setting.fixedsidebar': '固定侧边菜单',
+ 'app.setting.fixedsidebar.hint': '侧边菜单布局时可配置',
+ 'app.setting.hideheader': '下滑时隐藏 Header',
+ 'app.setting.hideheader.hint': '固定 Header 时可配置',
+ 'app.setting.othersettings': '其他设置',
+ 'app.setting.weakmode': '色弱模式',
+ 'app.setting.copy': '拷贝设置',
+ 'app.setting.copyinfo': '拷贝成功,请到 src/defaultSettings.js 中替换默认配置',
+ 'app.setting.production.hint':
+ '配置栏只在开发环境用于预览,生产环境不会展现,请拷贝后手动修改配置文件',
+};
diff --git a/src/manifest.json b/src/manifest.json
new file mode 100644
index 0000000..a4f731e
--- /dev/null
+++ b/src/manifest.json
@@ -0,0 +1,22 @@
+{
+ "name": "Apache APISIX Dashboard",
+ "short_name": "Apache APISIX Dashboard",
+ "display": "standalone",
+ "start_url": "./?utm_source=homescreen",
+ "theme_color": "#002140",
+ "background_color": "#001529",
+ "icons": [
+ {
+ "src": "icons/icon-192x192.png",
+ "sizes": "192x192"
+ },
+ {
+ "src": "icons/icon-128x128.png",
+ "sizes": "128x128"
+ },
+ {
+ "src": "icons/icon-512x512.png",
+ "sizes": "512x512"
+ }
+ ]
+}
diff --git a/src/pages/404.tsx b/src/pages/404.tsx
new file mode 100644
index 0000000..5b29934
--- /dev/null
+++ b/src/pages/404.tsx
@@ -0,0 +1,18 @@
+import { Button, Result } from 'antd';
+import React from 'react';
+import { history } from 'umi';
+
+const NoFoundPage: React.FC<{}> = () => (
+ <Result
+ status={404}
+ title="404"
+ subTitle="Sorry, the page you visited does not exist."
+ extra={
+ <Button type="primary" onClick={() => history.push('/')}>
+ Back Home
+ </Button>
+ }
+ />
+);
+
+export default NoFoundPage;
diff --git a/src/pages/Metrics/Metrics.tsx b/src/pages/Metrics/Metrics.tsx
new file mode 100644
index 0000000..7693883
--- /dev/null
+++ b/src/pages/Metrics/Metrics.tsx
@@ -0,0 +1,54 @@
+import React, { useState, useEffect } from 'react';
+import { PageHeaderWrapper } from '@ant-design/pro-layout';
+import { Empty, Button, Card } from 'antd';
+import { history } from 'umi';
+import { stringify } from 'qs';
+import { getSetting } from '@/pages/Setting';
+
+const Metrics: React.FC = () => {
+ const [grafanaURL, setGrafanaURL] = useState<string | undefined>();
+ const [showMetrics, setShowMetrics] = useState(false);
+
+ useEffect(() => {
+ const { grafanaURL: url } = getSetting();
+ setGrafanaURL(url);
+ setShowMetrics(Boolean(url));
+ }, []);
+
+ return (
+ <PageHeaderWrapper>
+ <Card>
+ {!showMetrics && (
+ <Empty
+ image="https://gw.alipayobjects.com/zos/antfincdn/ZHrcdLPrvN/empty.svg"
+ imageStyle={{
+ height: 60,
+ }}
+ description={<span>您还未配置 Grafana</span>}
+ >
+ <Button
+ type="primary"
+ onClick={() => {
+ history.replace({
+ pathname: '/setting',
+ search: stringify({
+ redirect: window.location.href,
+ }),
+ });
+ }}
+ >
+ 现在配置
+ </Button>
+ </Empty>
+ )}
+ {showMetrics && (
+ <div>
+ <iframe title="metrics" src={grafanaURL} width="100%" height="860" frameBorder="0" />
+ </div>
+ )}
+ </Card>
+ </PageHeaderWrapper>
+ );
+};
+
+export default Metrics;
diff --git a/src/pages/Routes/Create.less b/src/pages/Routes/Create.less
new file mode 100644
index 0000000..8d1eb14
--- /dev/null
+++ b/src/pages/Routes/Create.less
@@ -0,0 +1,112 @@
+@import '~antd/es/style/themes/default.less';
+
+.card {
+ margin-bottom: 24px;
+}
+
+.heading {
+ margin: 0 0 16px 0;
+ font-size: 14px;
+ line-height: 22px;
+}
+
+.steps:global(.ant-steps) {
+ max-width: 750px;
+ margin: 16px auto;
+}
+
+.errorIcon {
+ margin-right: 24px;
+ color: @error-color;
+ cursor: pointer;
+
+ span.anticon {
+ margin-right: 4px;
+ }
+}
+
+.errorPopover {
+ :global {
+ .ant-popover-inner-content {
+ min-width: 256px;
+ max-height: 290px;
+ padding: 0;
+ overflow: auto;
+ }
+ }
+}
+
+.errorListItem {
+ padding: 8px 16px;
+ list-style: none;
+ border-bottom: 1px solid @border-color-split;
+ cursor: pointer;
+ transition: all 0.3s;
+
+ &:hover {
+ background: @item-active-bg;
+ }
+
+ &:last-child {
+ border: 0;
+ }
+
+ .errorIcon {
+ float: left;
+ margin-top: 4px;
+ margin-right: 12px;
+ padding-bottom: 22px;
+ color: @error-color;
+ }
+
+ .errorField {
+ margin-top: 2px;
+ color: @text-color-secondary;
+ font-size: 12px;
+ }
+}
+
+.editable {
+ td {
+ padding-top: 13px !important;
+ padding-bottom: 12.5px !important;
+ }
+}
+
+// custom footer for fixed footer toolbar
+.advancedForm + div {
+ padding-bottom: 64px;
+}
+
+.advancedForm {
+ :global {
+ .ant-form .ant-row:last-child .ant-form-item {
+ margin-bottom: 24px;
+ }
+
+ .ant-table td {
+ transition: none !important;
+ }
+ }
+}
+
+.optional {
+ color: @text-color-secondary;
+ font-style: normal;
+}
+
+.stepForm {
+ max-width: 700px;
+ margin: 40px auto 0;
+}
+
+:global {
+ .ant-card.ant-card-bordered {
+ height: 250px;
+ }
+ .ant-card-actions {
+ position: absolute;
+ bottom: 0;
+ width: 100%;
+ }
+}
diff --git a/src/pages/Routes/Create.tsx b/src/pages/Routes/Create.tsx
new file mode 100644
index 0000000..1015358
--- /dev/null
+++ b/src/pages/Routes/Create.tsx
@@ -0,0 +1,223 @@
+import React, { useState, useEffect } from 'react';
+import { Card, Steps, Form } from 'antd';
+import { PageHeaderWrapper } from '@ant-design/pro-layout';
+
+import { PLUGIN_MAPPER_SOURCE } from '@/components/PluginForm/data';
+import { createRoute, fetchRoute, updateRoute, fetchPluginList } from './service';
+import Step1 from './components/Step1';
+import Step2 from './components/Step2';
+import CreateStep3 from './components/CreateStep3';
+import CreateStep4 from './components/CreateStep4';
+import {
+ DEFAULT_STEP_1_DATA,
+ DEFAULT_STEP_2_DATA,
+ DEFAULT_STEP_3_DATA,
+ STEP_HEADER_2,
+ STEP_HEADER_4,
+} from './constants';
+import ResultView from './components/ResultView';
+import ActionBar from './components/ActionBar';
+import styles from './Create.less';
+
+const { Step } = Steps;
+
+type Props = {
+ // FIXME
+ route: any;
+ match: any;
+};
+
+const Create: React.FC<Props> = (props) => {
+ const [step1Data, setStep1Data] = useState(DEFAULT_STEP_1_DATA);
+ const [step2Data, setStep2Data] = useState(DEFAULT_STEP_2_DATA);
+ const [step3Data, setStep3Data] = useState(DEFAULT_STEP_3_DATA);
+
+ const [redirect, setRedirect] = useState(false);
+
+ const [form1] = Form.useForm();
+ const [form2] = Form.useForm();
+
+ const [step, setStep] = useState(0);
+ const [stepHeader, setStepHeader] = useState(STEP_HEADER_4);
+
+ const routeData = {
+ step1Data,
+ step2Data,
+ step3Data,
+ };
+
+ const setupPlugin = () => {
+ const PLUGIN_BLOCK_LIST = Object.entries(PLUGIN_MAPPER_SOURCE)
+ .filter(([, value]) => value.hidden)
+ .flat()
+ .filter((item) => typeof item === 'string');
+
+ fetchPluginList().then((data: string[]) => {
+ const names = data.filter((name) => !PLUGIN_BLOCK_LIST.includes(name));
+
+ const enabledNames = Object.keys(step3Data.plugins);
+ const disabledNames = names.filter((name) => !enabledNames.includes(name));
+
+ setStep3Data({
+ plugins: step3Data.plugins,
+ _disabledPluginList: disabledNames.map((name) => ({
+ name,
+ ...PLUGIN_MAPPER_SOURCE[name],
+ })),
+ _enabledPluginList: enabledNames.map((name) => ({
+ name,
+ ...PLUGIN_MAPPER_SOURCE[name],
+ })),
+ });
+ });
+ };
+
+ const setupRoute = (rid: number) =>
+ fetchRoute(rid).then((data) => {
+ form1.setFieldsValue(data.step1Data);
+ setStep1Data(data.step1Data as RouteModule.Step1Data);
+
+ form2.setFieldsValue(data.step2Data);
+ setStep2Data(data.step2Data);
+
+ setStep3Data(data.step3Data);
+ });
+
+ useEffect(() => {
+ if (props.route.name === 'edit') {
+ setupRoute(props.match.params.rid).then(() => setupPlugin());
+ } else {
+ setupPlugin();
+ }
+ }, []);
+
+ useEffect(() => {
+ const { redirectOption } = step1Data;
+
+ if (redirectOption === 'customRedirect') {
+ setRedirect(true);
+ setStepHeader(STEP_HEADER_2);
+ return;
+ }
+ setRedirect(false);
+ setStepHeader(STEP_HEADER_4);
+ }, [step1Data]);
+
+ // FIXME
+ const onReset = () => {
+ setStep1Data(DEFAULT_STEP_1_DATA);
+ setStep2Data(DEFAULT_STEP_2_DATA);
+ setStep3Data(DEFAULT_STEP_3_DATA);
+ form1.resetFields();
+ form2.resetFields();
+ setStep(0);
+ };
+
+ const renderStep = () => {
+ if (step === 0) {
+ return (
+ <Step1
+ data={routeData}
+ form={form1}
+ onChange={(params: RouteModule.Step1Data) => {
+ setStep1Data({ ...step1Data, ...params });
+ }}
+ />
+ );
+ }
+
+ if (step === 1) {
+ if (redirect) {
+ return (
+ <CreateStep4 data={routeData} form1={form1} form2={form2} onChange={() => {}} redirect />
+ );
+ }
+
+ return (
+ <Step2
+ data={routeData}
+ form={form2}
+ onChange={(params: RouteModule.Step2Data) => setStep2Data({ ...step2Data, ...params })}
+ />
+ );
+ }
+
+ if (step === 2) {
+ return <CreateStep3 data={routeData} onChange={setStep3Data} />;
+ }
+
+ if (step === 3) {
+ return <CreateStep4 data={routeData} form1={form1} form2={form2} onChange={() => {}} />;
+ }
+
+ if (step === 4) {
+ return <ResultView onReset={onReset} />;
+ }
+
+ return null;
+ };
+
+ const onStepChange = (nextStep: number) => {
+ const onUpdateOrCreate = () => {
+ if ((props as any).route.name === 'edit') {
+ updateRoute((props as any).match.params.rid, { data: routeData }).then(() => {
+ setStep(4);
+ });
+ } else {
+ createRoute({ data: routeData }).then(() => {
+ setStep(4);
+ });
+ }
+ };
+
+ if (nextStep === 0) {
+ setStep(nextStep);
+ }
+
+ if (nextStep === 1) {
+ form1.validateFields().then((value) => {
+ setStep1Data({ ...step1Data, ...value });
+ setStep(nextStep);
+ });
+ return;
+ }
+
+ if (nextStep === 2) {
+ if (redirect) {
+ onUpdateOrCreate();
+ return;
+ }
+ form2.validateFields().then((value) => {
+ setStep2Data({ ...step2Data, ...value });
+ setStep(nextStep);
+ });
+ return;
+ }
+
+ if (nextStep === 3) {
+ setStep(nextStep);
+ }
+
+ if (nextStep === 4) {
+ onUpdateOrCreate();
+ }
+ };
+
+ return (
+ <>
+ <PageHeaderWrapper>
+ <Card bordered={false}>
+ <Steps current={step} className={styles.steps}>
+ {stepHeader.map((item) => (
+ <Step title={item} key={item} />
+ ))}
+ </Steps>
+ {renderStep()}
+ </Card>
+ </PageHeaderWrapper>
+ <ActionBar step={step} redirect={redirect} onChange={onStepChange} />
+ </>
+ );
+};
+
+export default Create;
diff --git a/src/pages/Routes/List.tsx b/src/pages/Routes/List.tsx
new file mode 100644
index 0000000..9c0e71c
--- /dev/null
+++ b/src/pages/Routes/List.tsx
@@ -0,0 +1,82 @@
+import React, { useRef } from 'react';
+import { PageHeaderWrapper } from '@ant-design/pro-layout';
+import ProTable, { ProColumns, ActionType } from '@ant-design/pro-table';
+import { history } from 'umi';
+import { Button, Popconfirm, notification } from 'antd';
+import { PlusOutlined } from '@ant-design/icons';
+
+import { ListItem } from '@/transforms/global';
+import { fetchRouteList, removeRoute } from './service';
+
+const RouteList: React.FC = () => {
+ const ref = useRef<ActionType>();
+
+ const columns: ProColumns<ListItem<RouteModule.BaseData>>[] = [
+ {
+ title: '名称',
+ dataIndex: 'name',
+ },
+ {
+ title: '优先级',
+ dataIndex: 'priority',
+ },
+ {
+ title: '描述',
+ dataIndex: 'description',
+ },
+ {
+ title: '更新时间',
+ dataIndex: 'update_time',
+ render: (text) => `${new Date(Number(text) * 1000).toLocaleString()}`,
+ },
+ {
+ title: '操作',
+ valueType: 'option',
+ render: (_, record) => (
+ <>
+ <Button
+ type="primary"
+ onClick={() => history.push(`/routes/${record.id}/edit`)}
+ style={{ marginRight: 10 }}
+ >
+ 编辑
+ </Button>
+ <Popconfirm
+ title="确定删除该路由吗?"
+ onConfirm={() => {
+ removeRoute(record.id!).then(() => {
+ notification.success({ message: '删除路由成功' });
+ /* eslint-disable no-unused-expressions */
+ ref.current?.reload();
+ });
+ }}
+ >
+ <Button type="primary" danger>
+ 删除
+ </Button>
+ </Popconfirm>
+ </>
+ ),
+ },
+ ];
+
+ return (
+ <PageHeaderWrapper>
+ <ProTable<ListItem<RouteModule.BaseData>>
+ actionRef={ref}
+ rowKey="name"
+ request={() => fetchRouteList()}
+ columns={columns}
+ search={false}
+ toolBarRender={() => [
+ <Button type="primary" onClick={() => history.push(`/routes/create`)}>
+ <PlusOutlined />
+ 创建
+ </Button>,
+ ]}
+ />
+ </PageHeaderWrapper>
+ );
+};
+
+export default RouteList;
diff --git a/src/pages/Routes/components/ActionBar/ActionBar.tsx b/src/pages/Routes/components/ActionBar/ActionBar.tsx
new file mode 100644
index 0000000..3abd3a2
--- /dev/null
+++ b/src/pages/Routes/components/ActionBar/ActionBar.tsx
@@ -0,0 +1,46 @@
+import React, { CSSProperties } from 'react';
+
+import { Row, Col, Button } from 'antd';
+
+interface Props {
+ step: number;
+ onChange(nextStep: number): void;
+ redirect?: boolean;
+}
+
+const style: CSSProperties = {
+ position: 'fixed',
+ bottom: 0,
+ right: 10,
+ margin: '-24px -24px 0',
+ backgroundColor: '#fff',
+ padding: '6px 36px',
+ borderTop: '1px solid #ebecec',
+ width: '100%',
+};
+
+const ActionBar: React.FC<Props> = ({ step, onChange, redirect }) => {
+ if (step > 3) {
+ return null;
+ }
+
+ return (
+ <div style={style}>
+ <Row gutter={10} justify="end">
+ <Col>
+ <Button type="primary" onClick={() => onChange(step - 1)} disabled={step === 0}>
+ 上一步
+ </Button>
+ </Col>
+ <Col>
+ <Button type="primary" onClick={() => onChange(step + 1)}>
+ {!redirect && (step < 3 ? '下一步' : '提交')}
+ {redirect && (step === 0 ? '下一步' : '提交')}
+ </Button>
+ </Col>
+ </Row>
+ </div>
+ );
+};
+
+export default ActionBar;
diff --git a/src/pages/Routes/components/ActionBar/index.ts b/src/pages/Routes/components/ActionBar/index.ts
new file mode 100644
index 0000000..d08aed0
--- /dev/null
+++ b/src/pages/Routes/components/ActionBar/index.ts
@@ -0,0 +1 @@
+export { default } from './ActionBar';
diff --git a/src/pages/Routes/components/CreateStep3/CreateStep3.tsx b/src/pages/Routes/components/CreateStep3/CreateStep3.tsx
new file mode 100644
index 0000000..59e08c2
--- /dev/null
+++ b/src/pages/Routes/components/CreateStep3/CreateStep3.tsx
@@ -0,0 +1,96 @@
+import React, { useState } from 'react';
+import { SettingOutlined, LinkOutlined } from '@ant-design/icons';
+import { omit, merge } from 'lodash';
+
+import { PLUGIN_MAPPER_SOURCE } from '@/components/PluginForm/data';
+import PanelSection from '../PanelSection';
+import PluginDrawer from './PluginDrawer';
+import PluginCard from './PluginCard';
+
+interface Props extends RouteModule.Data {}
+
+const sectionStyle = {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(3, 33.333%)',
+ gridRowGap: 10,
+ gridColumnGap: 10,
+};
+
+const CreateStep3: React.FC<Props> = ({ data, disabled, onChange }) => {
+ const [currentPlugin, setCurrentPlugin] = useState<string | undefined>();
+ const { _disabledPluginList = [], _enabledPluginList = [] } = data.step3Data;
+
+ return (
+ <>
+ <PanelSection title="已启用" style={sectionStyle}>
+ {_enabledPluginList.map(({ name }) => (
+ <PluginCard
+ name={name}
+ actions={[
+ <SettingOutlined onClick={() => setCurrentPlugin(name)} />,
+ <LinkOutlined
+ onClick={() =>
+ window.open(
+ `https://github.com/apache/incubator-apisix/blob/master/doc/plugins/${name}.md`,
+ )
+ }
+ />,
+ ]}
+ key={name}
+ />
+ ))}
+ </PanelSection>
+ {!disabled && (
+ <PanelSection title="未启用" style={sectionStyle}>
+ {_disabledPluginList.map(({ name }) => (
+ <PluginCard
+ name={name}
+ actions={[
+ <SettingOutlined onClick={() => setCurrentPlugin(name)} />,
+ <LinkOutlined
+ onClick={() =>
+ window.open(
+ `https://github.com/apache/incubator-apisix/blob/master/doc/plugins/${name}.md`,
+ )
+ }
+ />,
+ ]}
+ key={name}
+ />
+ ))}
+ </PanelSection>
+ )}
+ <PluginDrawer
+ name={currentPlugin}
+ disabled={disabled}
+ initialData={currentPlugin ? data.step3Data.plugins[currentPlugin] : {}}
+ active={Boolean(_enabledPluginList.find((item) => item.name === currentPlugin))}
+ onActive={(name: string) => {
+ onChange({
+ ...data.step3Data,
+ _disabledPluginList: _disabledPluginList.filter((item) => item.name !== name),
+ _enabledPluginList: _enabledPluginList.concat({ name, ...PLUGIN_MAPPER_SOURCE[name] }),
+ });
+ }}
+ onInactive={(name: string) => {
+ onChange({
+ ...omit({ ...data.step3Data }, `plugins.${currentPlugin}`),
+ _disabledPluginList: _disabledPluginList.concat({
+ name,
+ ...PLUGIN_MAPPER_SOURCE[name],
+ }),
+ _enabledPluginList: _enabledPluginList.filter((item) => item.name !== name),
+ });
+ setCurrentPlugin(undefined);
+ }}
+ onClose={() => setCurrentPlugin(undefined)}
+ onFinish={(value) => {
+ onChange(merge(data.step3Data, { plugins: { [currentPlugin as string]: value } }));
+ setCurrentPlugin(undefined);
+ }}
+ />
+ </>
+ );
+};
+
+export default CreateStep3;
diff --git a/src/pages/Routes/components/CreateStep3/PluginCard.tsx b/src/pages/Routes/components/CreateStep3/PluginCard.tsx
new file mode 100644
index 0000000..bb3857a
--- /dev/null
+++ b/src/pages/Routes/components/CreateStep3/PluginCard.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import { Card } from 'antd';
+import { CardProps } from 'antd/lib/card';
+import { useIntl } from 'umi';
+
+interface Props extends CardProps {
+ name: string;
+}
+
+const PluginCard: React.FC<Props> = ({ name, actions }) => {
+ const { formatMessage } = useIntl();
+
+ return (
+ <Card actions={actions}>
+ <Card.Meta
+ title={name}
+ description={formatMessage({
+ id: `PluginForm.plugin.${name}.desc`,
+ defaultMessage: 'Please view the documentation.',
+ })}
+ />
+ </Card>
+ );
+};
+
+export default PluginCard;
diff --git a/src/pages/Routes/components/CreateStep3/PluginDrawer.tsx b/src/pages/Routes/components/CreateStep3/PluginDrawer.tsx
new file mode 100644
index 0000000..b26d63f
--- /dev/null
+++ b/src/pages/Routes/components/CreateStep3/PluginDrawer.tsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import { Drawer, Button } from 'antd';
+import { useForm } from 'antd/es/form/util';
+
+import PluginForm from '@/components/PluginForm';
+
+interface Props extends Omit<PluginForm.Props, 'form'> {
+ active?: boolean;
+ disabled?: boolean;
+ onActive(name: string): void;
+ onInactive(name: string): void;
+ onClose(): void;
+}
+
+const PluginDrawer: React.FC<Props> = ({
+ name,
+ active,
+ disabled,
+ onActive,
+ onInactive,
+ onClose,
+ ...rest
+}) => {
+ const [form] = useForm();
+
+ if (!name) {
+ return null;
+ }
+
+ return (
+ <Drawer
+ title={`配置 ${name} 插件`}
+ width={400}
+ visible={Boolean(name)}
+ destroyOnClose
+ onClose={onClose}
+ footer={
+ disabled ? null : (
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+ <div>
+ {Boolean(active) && (
+ <Button type="primary" danger onClick={() => onInactive(name)}>
+ 禁用
+ </Button>
+ )}
+ {Boolean(!active) && (
+ <Button type="primary" onClick={() => onActive(name)}>
+ 启用
+ </Button>
+ )}
+ </div>
+ {Boolean(active) && (
+ <div>
+ <Button onClick={onClose}>取消</Button>
+ <Button
+ type="primary"
+ style={{ marginRight: 8, marginLeft: 8 }}
+ onClick={() => form.submit()}
+ >
+ 确认
+ </Button>
+ </div>
+ )}
+ </div>
+ )
+ }
+ >
+ <PluginForm name={name!} form={form} {...rest} disabled={disabled} />
+ </Drawer>
+ );
+};
+
+export default PluginDrawer;
diff --git a/src/pages/Routes/components/CreateStep3/index.ts b/src/pages/Routes/components/CreateStep3/index.ts
new file mode 100644
index 0000000..bb56257
--- /dev/null
+++ b/src/pages/Routes/components/CreateStep3/index.ts
@@ -0,0 +1 @@
+export { default } from './CreateStep3';
diff --git a/src/pages/Routes/components/CreateStep4/CreateStep4.tsx b/src/pages/Routes/components/CreateStep4/CreateStep4.tsx
new file mode 100644
index 0000000..a539b9f
--- /dev/null
+++ b/src/pages/Routes/components/CreateStep4/CreateStep4.tsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import { FormInstance } from 'antd/lib/form';
+
+import Step1 from '../Step1';
+import Step2 from '../Step2';
+import CreateStep3 from '../CreateStep3';
+
+interface Props extends RouteModule.Data {
+ form1: FormInstance;
+ form2: FormInstance;
+ redirect?: boolean;
+}
+
+const style = {
+ marginTop: '40px',
+};
+
+const CreateStep4: React.FC<Props> = ({ form1, form2, redirect, ...rest }) => {
+ return (
+ <>
+ <h2>定义 API 请求</h2>
+ <Step1 {...rest} form={form1} disabled />
+ {!redirect && (
+ <>
+ <h2 style={style}>定义 API 后端服务</h2>
+ <Step2 {...rest} form={form2} disabled />
+ <h2 style={style}>插件配置</h2>
+ <CreateStep3 {...rest} disabled />
+ </>
+ )}
+ </>
+ );
+};
+
+export default CreateStep4;
diff --git a/src/pages/Routes/components/CreateStep4/index.ts b/src/pages/Routes/components/CreateStep4/index.ts
new file mode 100644
index 0000000..b9b39e1
--- /dev/null
+++ b/src/pages/Routes/components/CreateStep4/index.ts
@@ -0,0 +1 @@
+export { default } from './CreateStep4';
diff --git a/src/pages/Routes/components/PanelSection/index.tsx b/src/pages/Routes/components/PanelSection/index.tsx
new file mode 100644
index 0000000..153942c
--- /dev/null
+++ b/src/pages/Routes/components/PanelSection/index.tsx
@@ -0,0 +1,16 @@
+import React, { CSSProperties } from 'react';
+import { Divider } from 'antd';
+
+const PanelSection: React.FC<{
+ title: string;
+ style?: CSSProperties;
+}> = ({ title, style, children }) => {
+ return (
+ <>
+ <Divider orientation="left">{title}</Divider>
+ <div style={style}>{children}</div>
+ </>
+ );
+};
+
+export default PanelSection;
diff --git a/src/pages/Routes/components/ResultView/ResultView.tsx b/src/pages/Routes/components/ResultView/ResultView.tsx
new file mode 100644
index 0000000..f4505b2
--- /dev/null
+++ b/src/pages/Routes/components/ResultView/ResultView.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import { Result, Button } from 'antd';
+import { history } from 'umi';
+
+type Props = {
+ onReset?(): void;
+};
+
+const ResultView: React.FC<Props> = () => (
+ <Result
+ status="success"
+ title="提交成功"
+ extra={[
+ <Button type="primary" key="goto-list" onClick={() => history.replace('/routes')}>
+ 返回路由列表
+ </Button>,
+ <Button key="create-new" onClick={() => history.replace('/routes/create')}>
+ 创建新路由
+ </Button>,
+ ]}
+ />
+);
+
+export default ResultView;
diff --git a/src/pages/Routes/components/ResultView/index.ts b/src/pages/Routes/components/ResultView/index.ts
new file mode 100644
index 0000000..94e7334
--- /dev/null
+++ b/src/pages/Routes/components/ResultView/index.ts
@@ -0,0 +1 @@
+export { default } from './ResultView';
diff --git a/src/pages/Routes/components/Step1/MatchingRulesView.tsx b/src/pages/Routes/components/Step1/MatchingRulesView.tsx
new file mode 100644
index 0000000..7e9cc17
--- /dev/null
+++ b/src/pages/Routes/components/Step1/MatchingRulesView.tsx
@@ -0,0 +1,214 @@
+import React, { useState } from 'react';
+import { Button, Table, Modal, Form, Select, Input, Space } from 'antd';
+
+import PanelSection from '../PanelSection';
+
+interface Props extends RouteModule.Data {}
+
+const MatchingRulesView: React.FC<Props> = ({ data, disabled, onChange }) => {
+ const { advancedMatchingRules } = data.step1Data;
+
+ const [visible, setVisible] = useState(false);
+ const [mode, setMode] = useState<RouteModule.ModalType>('CREATE');
+ const [namePlaceholder, setNamePlaceholder] = useState('');
+ const [modalForm] = Form.useForm();
+
+ const { Option } = Select;
+
+ const onOk = () => {
+ modalForm.validateFields().then((value) => {
+ if (mode === 'EDIT') {
+ const key = modalForm.getFieldValue('key');
+ onChange({
+ ...data.step1Data,
+ advancedMatchingRules: advancedMatchingRules.map((rule) => {
+ if (rule.key === key) {
+ return { ...(value as RouteModule.MatchingRule), key };
+ }
+ return rule;
+ }),
+ });
+ } else {
+ const rule = {
+ ...(value as RouteModule.MatchingRule),
+ key: Math.random().toString(36).slice(2),
+ };
+ onChange({ ...data.step1Data, advancedMatchingRules: advancedMatchingRules.concat(rule) });
+ }
+ modalForm.resetFields();
+ setVisible(false);
+ });
+ };
+
+ const handleEdit = (record: RouteModule.MatchingRule) => {
+ setMode('EDIT');
+ setVisible(true);
+ modalForm.setFieldsValue(record);
+ };
+
+ const handleRemove = (key: string) => {
+ onChange({
+ ...data.step1Data,
+ advancedMatchingRules: advancedMatchingRules.filter((item) => item.key !== key),
+ });
+ };
+
+ const columns = [
+ {
+ title: '参数位置',
+ key: 'position',
+ render: (text: RouteModule.MatchingRule) => {
+ let renderText;
+ switch (text.position) {
+ case 'http':
+ renderText = 'HTTP 请求头';
+ break;
+ case 'arg':
+ renderText = '请求参数';
+ break;
+ case 'cookie':
+ renderText = 'Cookie';
+ break;
+ default:
+ renderText = '';
+ }
+ return renderText;
+ },
+ },
+ {
+ title: '参数名称',
+ dataIndex: 'name',
+ key: 'name',
+ },
+ {
+ title: '运算符',
+ key: 'operator',
+ render: (text: RouteModule.MatchingRule) => {
+ let renderText;
+ switch (text.operator) {
+ case '==':
+ renderText = '等于';
+ break;
+ case '~=':
+ renderText = '不等于';
+ break;
+ case '>':
+ renderText = '大于';
+ break;
+ case '<':
+ renderText = '小于';
+ break;
+ case '~~':
+ renderText = '正则匹配';
+ break;
+ default:
+ renderText = '';
+ }
+ return renderText;
+ },
+ },
+ {
+ title: '参数值',
+ dataIndex: 'value',
+ key: 'value',
+ },
+ disabled
+ ? {}
+ : {
+ title: '操作',
+ key: 'action',
+ render: (_: any, record: RouteModule.MatchingRule) => (
+ <Space size="middle">
+ <a onClick={() => handleEdit(record)}>编辑</a>
+ <a onClick={() => handleRemove(record.key)}>删除</a>
+ </Space>
+ ),
+ },
+ ];
+
+ const renderModal = () => (
+ <Modal
+ title={mode === 'EDIT' ? '编辑规则' : '新建规则'}
+ centered
+ visible
+ onOk={onOk}
+ onCancel={() => {
+ setVisible(false);
+ modalForm.resetFields();
+ }}
+ okText="确定"
+ cancelText="取消"
+ destroyOnClose
+ >
+ <Form form={modalForm} labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
+ <Form.Item
+ label="参数位置"
+ name="position"
+ rules={[{ required: true, message: '请选择参数位置' }]}
+ >
+ <Select
+ onChange={(value) => {
+ if (value === 'http') {
+ setNamePlaceholder('请求头键名:例如 HOST');
+ } else {
+ setNamePlaceholder('参数名称:例如 id');
+ }
+ }}
+ >
+ <Option value="http">HTTP 请求头</Option>
+ <Option value="arg">请求参数</Option>
+ <Option value="cookie">Cookie</Option>
+ </Select>
+ </Form.Item>
+ <Form.Item
+ label="参数名称"
+ name="name"
+ rules={[{ required: true, message: '请输入参数名称' }]}
+ extra="只支持字母和数字,并且以字母开头"
+ >
+ <Input placeholder={namePlaceholder} />
+ </Form.Item>
+ <Form.Item
+ label="运算符"
+ name="operator"
+ rules={[{ required: true, message: '请选择运算符' }]}
+ >
+ <Select>
+ <Option value="==">等于</Option>
+ <Option value="~=">不等于</Option>
+ <Option value=">">大于</Option>
+ <Option value="<">小于</Option>
+ <Option value="~~">正则匹配</Option>
+ </Select>
+ </Form.Item>
+ <Form.Item label="值" name="value" rules={[{ required: true, message: '请输入参数值' }]}>
+ <Input />
+ </Form.Item>
+ </Form>
+ </Modal>
+ );
+
+ return (
+ <PanelSection title="高级路由匹配条件">
+ {!disabled && (
+ <Button
+ onClick={() => {
+ setMode('CREATE');
+ setVisible(true);
+ }}
+ type="primary"
+ style={{
+ marginBottom: 16,
+ }}
+ >
+ 新建
+ </Button>
+ )}
+ <Table key="table" bordered dataSource={advancedMatchingRules} columns={columns} />
+ {/* NOTE: tricky way, switch visible on Modal component will ocure error */}
+ {visible ? renderModal() : null}
+ </PanelSection>
+ );
+};
+
+export default MatchingRulesView;
diff --git a/src/pages/Routes/components/Step1/MetaView.tsx b/src/pages/Routes/components/Step1/MetaView.tsx
new file mode 100644
index 0000000..baac193
--- /dev/null
+++ b/src/pages/Routes/components/Step1/MetaView.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import Form from 'antd/es/form';
+import { Input } from 'antd';
+
+import PanelSection from '../PanelSection';
+
+interface Props extends RouteModule.Data {}
+
+const MetaView: React.FC<Props> = ({ disabled }) => {
+ return (
+ <PanelSection title="名称及其描述">
+ <Form.Item
+ label="API 名称"
+ name="name"
+ rules={[{ required: true, message: '请输入 API 名称' }]}
+ extra="支持英文,数字,下划线和减号,且只能以英文开头"
+ >
+ <Input placeholder="请输入 API 名称" disabled={disabled} />
+ </Form.Item>
+ <Form.Item label="描述" name="desc">
+ <Input.TextArea placeholder="不超过 200 个字符" disabled={disabled} />
+ </Form.Item>
+ </PanelSection>
+ );
+};
+
+export default MetaView;
diff --git a/src/pages/Routes/components/Step1/RequestConfigView.tsx b/src/pages/Routes/components/Step1/RequestConfigView.tsx
new file mode 100644
index 0000000..2424a4a
--- /dev/null
+++ b/src/pages/Routes/components/Step1/RequestConfigView.tsx
@@ -0,0 +1,201 @@
+import React from 'react';
+import Form from 'antd/es/form';
+import { Checkbox, Button, Input, Switch, Select, Row, Col } from 'antd';
+import { PlusOutlined, MinusCircleOutlined } from '@ant-design/icons';
+import { CheckboxValueType } from 'antd/lib/checkbox/Group';
+
+import {
+ HTTP_METHOD_OPTION_LIST,
+ FORM_ITEM_LAYOUT,
+ FORM_ITEM_WITHOUT_LABEL,
+} from '@/pages/Routes/constants';
+
+import PanelSection from '../PanelSection';
+
+interface Props extends RouteModule.Data {}
+
+const RequestConfigView: React.FC<Props> = ({ data, disabled, onChange }) => {
+ const { step1Data } = data;
+ const { protocols } = step1Data;
+ const onProtocolChange = (e: CheckboxValueType[]) => {
+ if (!e.includes('http') && !e.includes('https')) return;
+ onChange({ ...data.step1Data, protocols: e });
+ };
+ const renderHosts = () => (
+ <Form.List name="hosts">
+ {(fields, { add, remove }) => {
+ return (
+ <div>
+ {fields.map((field, index) => (
+ <Form.Item
+ {...(index === 0 ? FORM_ITEM_LAYOUT : FORM_ITEM_WITHOUT_LABEL)}
+ label={index === 0 ? '域名' : ''}
+ required
+ key={field.key}
+ extra={index === 0 ? '域名或 IP:支持泛域名,如 "*.test.com"' : ''}
+ >
+ <Form.Item
+ {...field}
+ validateTrigger={['onChange', 'onBlur']}
+ rules={[
+ {
+ required: true,
+ whitespace: true,
+ message: '请输入域名',
+ },
+ ]}
+ noStyle
+ >
+ <Input placeholder="请输入域名" style={{ width: '60%' }} disabled={disabled} />
+ </Form.Item>
+ {!disabled && fields.length > 1 ? (
+ <MinusCircleOutlined
+ className="dynamic-delete-button"
+ style={{ margin: '0 8px' }}
+ onClick={() => {
+ remove(field.name);
+ }}
+ />
+ ) : null}
+ </Form.Item>
+ ))}
+ {!disabled && (
+ <Form.Item {...FORM_ITEM_WITHOUT_LABEL}>
+ <Button
+ type="dashed"
+ onClick={() => {
+ add();
+ }}
+ >
+ <PlusOutlined /> 新建
+ </Button>
+ </Form.Item>
+ )}
+ </div>
+ );
+ }}
... 21592 lines suppressed ...