tuon před 1 rokem
revize
164be3cf58
100 změnil soubory, kde provedl 6963 přidání a 0 odebrání
  1. 1 0
      .env
  2. 1 0
      .env.production
  3. 14 0
      .eslintrc.cjs
  4. 32 0
      .gitignore
  5. 21 0
      LICENSE
  6. 92 0
      README.md
  7. 4 0
      build-server.cmd
  8. 3 0
      build.sh
  9. 6 0
      dev.sh
  10. 8 0
      dist/assets/index-96b4a255.js
  11. 0 0
      dist/assets/index-976dda2b.css
  12. 4 0
      dist/config.js
  13. 46 0
      dist/index.html
  14. 1 0
      dist/vite.svg
  15. 22 0
      docker-compose-dev.yaml
  16. 16 0
      docker-compose.yaml
  17. 29 0
      docker/Dockerfile
  18. 30 0
      docker/Dockerfile-with-nginx
  19. 15 0
      docker/entrypoint.sh
  20. binární
      docs/images/dashboard.png
  21. binární
      docs/images/list.png
  22. binární
      docs/images/server.png
  23. binární
      docs/images/upstream.png
  24. 14 0
      index.html
  25. 64 0
      package.json
  26. 4 0
      public/config.js
  27. 1 0
      public/vite.svg
  28. 11 0
      server/conf/app.conf
  29. 40 0
      server/config/config.go
  30. 203 0
      server/controllers/certificate.go
  31. 29 0
      server/controllers/config.go
  32. 85 0
      server/controllers/default.go
  33. 173 0
      server/controllers/file.go
  34. 250 0
      server/controllers/nginx.go
  35. 128 0
      server/controllers/server.go
  36. binární
      server/data/db/sqlite.db
  37. 27 0
      server/db/db.go
  38. 43 0
      server/go.mod
  39. 342 0
      server/go.sum
  40. 15 0
      server/main.go
  41. 16 0
      server/models/file.go
  42. 73 0
      server/models/nginx.go
  43. 285 0
      server/nginx/instance.go
  44. 66 0
      server/nginx/local.go
  45. 40 0
      server/nginx/manager.go
  46. 127 0
      server/nginx/remote.go
  47. 62 0
      server/routers/router.go
  48. 0 0
      server/static/web/web
  49. 39 0
      server/tests/default_test.go
  50. 12 0
      server/utils/cert.go
  51. 8 0
      server/utils/cert_test.go
  52. 54 0
      server/utils/file.go
  53. 19 0
      server/utils/file_test.go
  54. 6 0
      server/views/index.tpl
  55. 41 0
      src/App.css
  56. 28 0
      src/App.tsx
  57. 27 0
      src/adapter/index.js
  58. 120 0
      src/api/nginx.ts
  59. 68 0
      src/api/request.ts
  60. 0 0
      src/assets/react.svg
  61. 29 0
      src/components/BackButton.tsx
  62. 699 0
      src/config/nginx_form.json
  63. 68 0
      src/config/nginx_template.json
  64. 75 0
      src/index.css
  65. 56 0
      src/main.tsx
  66. 41 0
      src/models/api.ts
  67. 279 0
      src/models/nginx.ts
  68. 52 0
      src/pages/nginx/certs/index.less
  69. 249 0
      src/pages/nginx/certs/index.tsx
  70. 54 0
      src/pages/nginx/components/EditNginxBtn.tsx
  71. 97 0
      src/pages/nginx/components/StopStartButton.tsx
  72. 39 0
      src/pages/nginx/components/auth/config.json
  73. 10 0
      src/pages/nginx/components/auth/index.less
  74. 40 0
      src/pages/nginx/components/auth/index.tsx
  75. 19 0
      src/pages/nginx/components/basic/index.less
  76. 108 0
      src/pages/nginx/components/basic/index.tsx
  77. 52 0
      src/pages/nginx/components/certs/index.tsx
  78. 53 0
      src/pages/nginx/components/cors/config.json
  79. 35 0
      src/pages/nginx/components/cors/index.less
  80. 110 0
      src/pages/nginx/components/cors/index.tsx
  81. 32 0
      src/pages/nginx/components/error/config.json
  82. 2 0
      src/pages/nginx/components/error/index.less
  83. 73 0
      src/pages/nginx/components/error/index.tsx
  84. 64 0
      src/pages/nginx/components/gzip/config.json
  85. 10 0
      src/pages/nginx/components/gzip/index.less
  86. 97 0
      src/pages/nginx/components/gzip/index.tsx
  87. 9 0
      src/pages/nginx/components/index.ts
  88. 28 0
      src/pages/nginx/components/input.ts
  89. 272 0
      src/pages/nginx/components/location/config.json
  90. 42 0
      src/pages/nginx/components/location/index.less
  91. 281 0
      src/pages/nginx/components/location/index.tsx
  92. 108 0
      src/pages/nginx/components/location/utils.ts
  93. 195 0
      src/pages/nginx/components/proxy/config.json
  94. 29 0
      src/pages/nginx/components/proxy/index.less
  95. 87 0
      src/pages/nginx/components/proxy/index.tsx
  96. 70 0
      src/pages/nginx/components/proxy/utils.ts
  97. 31 0
      src/pages/nginx/components/proxypass/index.less
  98. 97 0
      src/pages/nginx/components/proxypass/index.tsx
  99. 70 0
      src/pages/nginx/components/proxypass/stream.tsx
  100. 336 0
      src/pages/nginx/components/site/components/Dragger.tsx

+ 1 - 0
.env

@@ -0,0 +1 @@
+VITE_BASE_API=/api

+ 1 - 0
.env.production

@@ -0,0 +1 @@
+VITE_BASE_API=/

+ 14 - 0
.eslintrc.cjs

@@ -0,0 +1,14 @@
+module.exports = {
+  env: { browser: true, es2020: true },
+  extends: [
+    'eslint:recommended',
+    'plugin:@typescript-eslint/recommended',
+    'plugin:react-hooks/recommended',
+  ],
+  parser: '@typescript-eslint/parser',
+  parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
+  plugins: ['react-refresh'],
+  rules: {
+    'react-refresh/only-export-components': 'warn',
+  },
+}

+ 32 - 0
.gitignore

@@ -0,0 +1,32 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+/local
+
+node_modules
+#dist
+#dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+*/.idea
+
+docker/data
+
+server/out
+server/data/files

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 niantuo
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 92 - 0
README.md

@@ -0,0 +1,92 @@
+# nginx 可视化界面
+项目的主要功能未nginx的配置管理,通过可视化的界面去配置nginx,所有的配置渲染逻辑都在前端进行,通过后台服务渲染到部署nginx的服务器上;\
+由于nginx的配置实在是太多了,只是可视化了部分常用的功能;\
+可以用于开发环境,需要经常变动一些配置信息的场景.
+
+- 项目的web前端基于react开发,使用vite构建工具;
+- 后端使用golang语言开发(菜鸟学习中)
+
+## 构建
+项目构建基于docker-compose, 分为两种情况
+- 基础镜像为nginx
+  容器自带nginx,启用web服务
+    ```
+  docker-compose -f ./docker-compose-dev.yaml build
+  ```
+- 基础镜像为debian:sid-slim
+  镜像不包含nginx,仅启动web服务
+    ```
+  docker-compose -f ./docker-compose.yaml build
+  ```
+前端在本地构建,以上构建方式不包含前端
+
+## 部署
+### 使用docker部署
+- 将docker-compose.yaml 或者 docker-compose-dev.yaml 复制到自己的文件夹下
+修改编排中的volumes,更改目录映射,容器内 /app/data 为目录持久化数据所在目录。
+```shell
+docker-compose -f ./docker-compose.yaml up -d
+```
+使用IP:8080端口访问
+或者
+```shell
+docker run -itd -v ./data/:/app/data --network host --name tuonina/nginx-with-ui
+# or
+docker run -itd -v ./data/:/app/data -p8080:8080 --name tuonina/nginx-ui
+```
+
+### 本地部署
+```shell
+# 下载构建产物,解压
+
+```
+
+## 截图
+- 实例列表
+  ![](./docs/images/list.png)
+- 实例信息
+  ![](./docs/images/dashboard.png)
+- 负载均衡
+  ![](./docs/images/upstream.png)
+- 虚拟主机
+  ![](./docs/images/server.png)
+
+## 参考文档
+配置部分参考一下文档:
+
+- [Nginx Rewrite](https://blog.csdn.net/qq1356059950/article/details/125014248)
+
+
+## 构建部署
+以下操作进入到项目根目录执行
+### 构建
+ docker-compose build 或者执行脚本sh build.sh
+
+## nginx-ui优化点
+- [x] nginx实例列表界面,添加完实例之后,弹窗没有关闭,且没有自动刷新当前界面
+- [x] server或者location的rewrite是否配置的判定问题
+- [x] nginx 实例设置时,将需要的目录文件创建好
+- [x] 新增虚拟主机或者其它新增界面,无法重置当前的表单,需要结合planning-tools,增加重置功能
+- [x] 证书管理,将证书信息保存到数据库,方便做nginx服务前移,不能直接写文件,可以增加从文件夹同步的功能
+- [x] 后端docker启动时,默认启动本地的NGINX,docker镜像问题
+- [ ] 很多界面的默认值问题,优化初始值
+- [x] nginx.conf默认值问题,每次容器重启都会被重置 
+- [x] 增强证书管理:添加时间,有效期,域名
+- [x] 增加一些快捷按钮,比如转发真实IP,支持websocket,跨域设置等
+- [x] 考虑是否增加静态站点的文件上传功能
+- [ ] 考虑多租户的功能,目前前端使用的是nginx auth的授权认证,该方式是否能传递用户id作为查询数据的条件
+- [ ] 考虑增加jwt,basic授权
+
+### 2023-07-06
+- [ ] [ngx_http_auth_request_module](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html)
+    鉴权模块的实现
+
+## 更新日志
+- 20230710:修复return 语句未渲染的问题
+- 20230719: 修复return语句在代理或者静态站点的情况下依然渲染的问题
+
+## git代理
+git config --global http.proxy 127.0.0.1 1234
+git config --global https.proxy  127.0.0.1 1234
+git config --global --unset http.proxy
+git config --global --unset https.proxy

+ 4 - 0
build-server.cmd

@@ -0,0 +1,4 @@
+cd ./server
+set GOODS=linux
+set GOARCH=amd64
+go build -o ../local/server

+ 3 - 0
build.sh

@@ -0,0 +1,3 @@
+#/usr/bin/sh
+export DOCKER_BUILDKIT=1
+docker build . -f docker/Dockerfile  -t nginx-ui:latest

+ 6 - 0
dev.sh

@@ -0,0 +1,6 @@
+#/usr/bin/sh
+export DOCKER_BUILDKIT=1
+docker-compose -f ./docker-compose-dev.yaml build
+docker-compose -f ./docker-compose-dev.yaml up -d
+
+

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 8 - 0
dist/assets/index-96b4a255.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
dist/assets/index-976dda2b.css


+ 4 - 0
dist/config.js

@@ -0,0 +1,4 @@
+// config.js
+window.CONFIG = {
+    baseApi: '/api/nginx-ui/api'
+}

+ 46 - 0
dist/index.html

@@ -0,0 +1,46 @@
+<!DOCTYPE html><html lang="en"><head>
+    <meta charset="UTF-8">
+    <link rel="icon" type="image/svg+xml" href="/nginx-ui/vite.svg">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>NginxUI</title>
+    <script type="application/javascript" src="./config.js"></script>
+    <script crossorigin="">import('/nginx-ui/assets/index-96b4a255.js').finally(() => {
+            
+    const qiankunLifeCycle = window.moudleQiankunAppLifeCycles && window.moudleQiankunAppLifeCycles['nginx-ui'];
+    if (qiankunLifeCycle) {
+      window.proxy.vitemount((props) => qiankunLifeCycle.mount(props));
+      window.proxy.viteunmount((props) => qiankunLifeCycle.unmount(props));
+      window.proxy.vitebootstrap(() => qiankunLifeCycle.bootstrap());
+      window.proxy.viteupdate((props) => qiankunLifeCycle.update(props));
+    }
+  
+          })</script>
+    <link rel="stylesheet" href="/nginx-ui/assets/index-976dda2b.css">
+  </head>
+  <body>
+    <div id="nginx_ui_root"></div>
+    
+  
+
+<script>
+  const createDeffer = (hookName) => {
+    const d = new Promise((resolve, reject) => {
+      window.proxy && (window.proxy[`vite${hookName}`] = resolve)
+    })
+    return props => d.then(fn => fn(props));
+  }
+  const bootstrap = createDeffer('bootstrap');
+  const mount = createDeffer('mount');
+  const unmount = createDeffer('unmount');
+  const update = createDeffer('update');
+
+  ;(global => {
+    global.qiankunName = 'nginx-ui';
+    global['nginx-ui'] = {
+      bootstrap,
+      mount,
+      unmount,
+      update
+    };
+  })(window);
+</script></body></html>

+ 1 - 0
dist/vite.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

+ 22 - 0
docker-compose-dev.yaml

@@ -0,0 +1,22 @@
+## 以nginx为基础镜像,方便调试,或者直接使用nginx容器
+
+version: "3"
+
+services:
+  nginx-with-ui:
+    build:
+      context: ./
+      dockerfile: docker/Dockerfile-with-nginx
+    environment:
+      - DOCKER_BUILDKIT=1
+    image: tuonina/nginx-with-ui
+    restart: always
+    container_name: nginx-with-ui
+    ports:
+      - 8081:8080
+      - 9090:9090
+      - 9080:80
+      - 9443:443
+#    network_mode: host
+    volumes:
+      - ./docker/data:/app/data

+ 16 - 0
docker-compose.yaml

@@ -0,0 +1,16 @@
+version: "3"
+
+services:
+  nginx-ui:
+    build:
+      context: ./
+      dockerfile: docker/Dockerfile
+    environment:
+      - DOCKER_BUILDKIT=1
+    image: tuonina/nginx-ui
+    restart: always
+    container_name: nginx-ui
+    ports:
+      - 8080:8080
+    volumes:
+      - ./data:/app/data

+ 29 - 0
docker/Dockerfile

@@ -0,0 +1,29 @@
+FROM golang:1.20 AS builder
+WORKDIR /app
+COPY server .
+ARG GOODS=linux
+ARG GOARCH=amd64
+
+RUN go env -w GOPROXY=https://goproxy.cn,direct
+
+RUN --mount=type=cache,target=/go --mount=type=cache,target=/root/.cache/go-build\
+    GOODS=${GOODS} GOARCH=${GOARCH}  go build -o /app/app
+
+FROM debian:sid-slim
+
+WORKDIR /app
+COPY --from=builder /app/app /app
+COPY server/conf /app/conf
+COPY server/data  /app/data
+#COPY ../server/static  /app/static
+COPY dist  /app/static/web
+
+RUN chmod +x /app/app
+
+RUN rm -rf /app/static/web/config.js
+
+#RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list
+#RUN sed -i 's/security.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list
+#RUN echo "deb http://mirrors.ustc.edu.cn/debian sid main" >> /etc/apt/sources.list
+
+ENTRYPOINT ["/app/app"]

+ 30 - 0
docker/Dockerfile-with-nginx

@@ -0,0 +1,30 @@
+FROM golang:1.20 AS builder
+WORKDIR /app
+COPY server .
+ARG GOODS=linux
+ARG GOARCH=amd64
+
+RUN go env -w GOPROXY=https://goproxy.cn,direct
+
+RUN --mount=type=cache,target=/go --mount=type=cache,target=/root/.cache/go-build\
+    GOODS=${GOODS} GOARCH=${GOARCH}  go build -o /app/app
+
+FROM nginx:1.25.1
+
+WORKDIR /app
+COPY --from=builder /app/app /app
+COPY server/conf /app/conf
+COPY server/data  /app/data
+#COPY ../server/static  /app/static
+COPY dist  /app/static/web
+COPY docker/entrypoint.sh /entrypoint.sh
+
+RUN chmod +x /entrypoint.sh /app/app
+
+RUN rm -rf /app/static/web/config.js
+RUN rm -rf /etc/nginx/conf.d
+#RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list
+#RUN sed -i 's/security.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list
+#RUN echo "deb http://mirrors.ustc.edu.cn/debian sid main" >> /etc/apt/sources.list
+
+ENTRYPOINT ["/entrypoint.sh"]

+ 15 - 0
docker/entrypoint.sh

@@ -0,0 +1,15 @@
+#!/bin/sh
+
+
+dataDir=${DATADIR:-/app/data}
+echo "dataDir: $dataDir"
+
+if [ -f "$dataDir/nginx.conf" ];then
+  if [ -f /etc/nginx/nginx.conf ]; then
+      mv -f /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak
+  fi
+  ln -s $dataDir/nginx.conf  /etc/nginx/nginx.conf
+fi
+nginx -g "daemon on;"
+## 启用 nginx-ui的服务
+/app/app

binární
docs/images/dashboard.png


binární
docs/images/list.png


binární
docs/images/server.png


binární
docs/images/upstream.png


+ 14 - 0
index.html

@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>NginxUI</title>
+    <script type="application/javascript" src="./config.js"></script>
+  </head>
+  <body>
+    <div id="nginx_ui_root"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>

+ 64 - 0
package.json

@@ -0,0 +1,64 @@
+{
+  "name": "nginx-ui-react",
+  "private": false,
+  "version": "0.1.0",
+  "license": "MIT",
+  "author": {
+    "email": "976056042@qq.com",
+    "name": "tuonina"
+  },
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "tsc && vite build --base=/nginx-ui/",
+    "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@ant-design/icons": "^5.1.4",
+    "@mdx-js/mdx": "^2.0.0-rc.2",
+    "@mdx-js/react": "^2.3.0",
+    "@mdx-js/rollup": "^2.3.0",
+    "@reduxjs/toolkit": "^1.9.5",
+    "antd": "4.x",
+    "artt-template": "^4.13.6",
+    "classnames": "^2.3.2",
+    "events": "^3.3.0",
+    "history": "^5.3.0",
+    "install": "^0.13.0",
+    "jszip": "^3.10.1",
+    "less": "^4.1.3",
+    "lodash": "^4.17.21",
+    "npm": "^9.8.0",
+    "planning-tools": "^0.1.1",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0",
+    "react-redux": "^8.1.1",
+    "react-router": "^6.14.0",
+    "react-router-dom": "^6.14.0",
+    "redux-persist": "^6.0.0"
+  },
+  "devDependencies": {
+    "@types/events": "^3.0.0",
+    "@types/lodash": "^4.14.195",
+    "@types/react": "^18.0.37",
+    "@types/react-dom": "^18.0.11",
+    "@typescript-eslint/eslint-plugin": "^5.59.0",
+    "@typescript-eslint/parser": "^5.59.0",
+    "@vitejs/plugin-react-swc": "^3.0.0",
+    "consola": "^3.1.0",
+    "eslint": "^8.38.0",
+    "eslint-plugin-react-hooks": "^4.6.0",
+    "eslint-plugin-react-refresh": "^0.3.4",
+    "typescript": "^5.0.2",
+    "vite": "^4.3.9",
+    "vite-plugin-md": "^0.21.5",
+    "vite-plugin-mdx": "^3.5.11",
+    "vite-plugin-qiankun": "^1.0.15",
+    "vite-plugin-require-transform": "^1.0.20",
+    "vite-plugin-style-import": "^2.0.0"
+  },
+  "peerDependencies": {
+    "artt-template": "^4.13.6"
+  }
+}

+ 4 - 0
public/config.js

@@ -0,0 +1,4 @@
+// config.js
+window.CONFIG = {
+    baseApi: '/api/nginx-ui/api'
+}

+ 1 - 0
public/vite.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

+ 11 - 0
server/conf/app.conf

@@ -0,0 +1,11 @@
+appname = server
+httpport = 8080
+runmode = dev
+copyrequestbody = true
+
+baseApi = /nginx-ui/api
+
+datadir = ./data
+dbdir = ./data/db
+nginxPath = /usr/sbin/nginx
+nginxDir = /etc/nginx

+ 40 - 0
server/config/config.go

@@ -0,0 +1,40 @@
+package config
+
+import (
+	"github.com/astaxie/beego"
+	"github.com/astaxie/beego/logs"
+	"os"
+	"server/utils"
+)
+
+type AppConfig struct {
+	BaseApi string
+	DataDir string
+}
+
+var Config = &AppConfig{}
+
+func GetDataDir() string {
+	return Config.DataDir
+}
+
+func init() {
+	// 需要和前端配置好
+	baseApi := beego.AppConfig.String("baseApi")
+	if baseApi == "" {
+		baseApi = "/ngx"
+		err := beego.AppConfig.Set("baseApi", baseApi)
+		if err != nil {
+			logs.Info("init set baseApi", err)
+		}
+	}
+	Config.BaseApi = baseApi
+	Config.DataDir = beego.AppConfig.String("datadir")
+	if exist := utils.IsExist(Config.DataDir); exist == false {
+		err := os.MkdirAll(Config.DataDir, 0777)
+		logs.Warn("create data dir fail", err)
+		if err != nil {
+			panic(err)
+		}
+	}
+}

+ 203 - 0
server/controllers/certificate.go

@@ -0,0 +1,203 @@
+package controllers
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"github.com/astaxie/beego/logs"
+	"github.com/astaxie/beego/orm"
+	"server/models"
+	nginx2 "server/nginx"
+	"server/utils"
+	"strconv"
+	"strings"
+	"time"
+)
+
+type CertController struct {
+	BaseController
+}
+
+// 这个是根据域名的名称来的,自定义命令,即文件名称
+func saveOrUpdate(cert *models.NginxCerts) error {
+	o := orm.NewOrm()
+	find := models.NginxCerts{ServiceName: cert.ServiceName, NginxId: cert.NginxId}
+	err := o.Read(&find, "service_name", "nginx_id")
+	if err != nil && err != orm.ErrNoRows {
+		return err
+	}
+	if err == orm.ErrNoRows {
+		_, err := o.Insert(cert)
+		return err
+	}
+	_, err = o.Update(cert)
+	return err
+}
+
+func (c *CertController) getNginx() *models.Nginx {
+	idStr := c.getParam(":id")
+	id, err := strconv.Atoi(idStr)
+	logs.Info("id", id)
+	if err != nil {
+		c.ErrorJson(err)
+		return nil
+	}
+	var nginx = models.Nginx{
+		Id: id,
+	}
+	o := orm.NewOrm()
+	err = o.Read(&nginx)
+	if err != nil {
+		c.ErrorJson(err)
+		return nil
+	}
+	if nginx.DataDir == "" {
+		c.setCode(-1).setMsg("请先配置数据目录位置!").json()
+		return nil
+	}
+	return &nginx
+}
+
+// Get getAll
+func (c *CertController) Get() {
+	nginx := c.getNginx()
+	if nginx == nil {
+		return
+	}
+	o := orm.NewOrm()
+	var list []models.NginxCerts
+	_, err := o.QueryTable((*models.NginxCerts)(nil)).Filter("NginxId", nginx.Id).All(&list)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	c.setData(list).json()
+}
+
+// Sync 从配置的证书路径同步证书到数据库
+func (c *CertController) Sync() {
+	nginx := c.getNginx()
+	if nginx == nil {
+		return
+	}
+	ins := nginx2.GetInstance(nginx)
+	names := strings.Split(ins.GetCerts(), "\n")
+	var certs []models.NginxCerts
+	for i := range names {
+		name := names[i]
+		if strings.HasSuffix(name, ".key") {
+			serviceName := name[0 : len(name)-4]
+			cert, err := ins.GetCertData(serviceName)
+			cert.NginxId = nginx.Id
+			cert.CreatedAt = time.Now().Format("2006-02-01 15:04")
+			if err != nil {
+				logs.Warn("getCertData fail", err, serviceName)
+			} else {
+				err = saveOrUpdate(cert)
+				if err != nil {
+					logs.Warn("save certs fail", err, serviceName)
+				} else {
+					certs = append(certs, *cert)
+				}
+			}
+		}
+	}
+	c.setData(true).json()
+}
+
+// Post save certs
+func (c *CertController) Post() {
+	nginx := c.getNginx()
+	if nginx == nil {
+		return
+	}
+	var cert models.NginxCerts
+	err := json.Unmarshal(c.Ctx.Input.RequestBody, &cert)
+	if err != nil {
+		logs.Error(err, string(c.Ctx.Input.RequestBody))
+		c.ErrorJson(err)
+		return
+	}
+	if cert.Pem == "" || cert.Key == "" {
+		c.setCode(-1).setMsg("请输入证书私钥和公钥内容!").json()
+		return
+	}
+
+	parse, err := utils.CheckHttps(cert.Pem)
+	if err != nil {
+		cert.HintMsg = fmt.Sprintf("证书公钥解析异常:%s", err.Error())
+	} else {
+		cert.ExpiresAt = parse.NotAfter.Format("2006-01-02 15:04")
+		cert.SubjectName = parse.Subject.CommonName
+	}
+	logs.Info("parse", cert.SubjectName)
+	o := orm.NewOrm()
+	cert.NginxId = nginx.Id
+	if cert.Id > 0 {
+		_, err = o.Update(&cert)
+	} else {
+		cert.CreatedAt = time.Now().Format("2006-01-02 15:04")
+		_, err = o.Insert(&cert)
+	}
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	ins := nginx2.GetInstance(nginx)
+	err = ins.SaveCerts(&cert)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	c.json()
+}
+
+// Delete del certs
+func (c *CertController) Delete() {
+	nginx := c.getNginx()
+	if nginx == nil {
+		return
+	}
+	ins := nginx2.GetInstance(nginx)
+	certId, err := c.GetInt("id", -1)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	if certId < 0 {
+		c.ErrorJson(errors.New("参数错误"))
+		return
+	}
+
+	dirs := ins.CheckDirs()
+	if dirs.CertsDir == "" || dirs.CertsDir == "/" {
+		c.setCode(-1).setMsg("请先配置证书路径,不能为根路径。")
+		c.json()
+		return
+	}
+	o := orm.NewOrm()
+	cert := models.NginxCerts{Id: certId, NginxId: nginx.Id}
+	err = o.Read(&cert, "id", "nginx_id")
+	if err != nil && err != orm.ErrNoRows {
+		c.ErrorJson(err)
+		return
+	} else if err != nil && err == orm.ErrNoRows {
+		c.json()
+		return
+	}
+
+	_, err = o.Delete(&cert)
+	if err != nil && err != orm.ErrNoRows {
+		c.ErrorJson(err)
+		return
+	}
+	certName := cert.ServiceName
+	cmd1 := fmt.Sprintf("cd %s && if [ -f %s.key ];then mv -f %s.key %s;fi", dirs.CertsDir, certName, certName, dirs.BackupDir)
+	cmd2 := fmt.Sprintf("cd %s && if [ -f %s.pem ];then mv -f %s.pem %s;fi", dirs.CertsDir, certName, certName, dirs.BackupDir)
+	resp, err := ins.Run(fmt.Sprintf("%s;%s", cmd1, cmd2))
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	c.setData(resp).json()
+}

+ 29 - 0
server/controllers/config.go

@@ -0,0 +1,29 @@
+package controllers
+
+import (
+	"fmt"
+	"github.com/astaxie/beego/logs"
+	config2 "server/config"
+)
+
+type ConfigController struct {
+	BaseController
+}
+
+// Get 前端的配置文件
+func (c *ConfigController) Get() {
+
+	config := config2.Config
+
+	js := fmt.Sprintf("  window.CONFIG = {\n        baseApi: '%s'\n    }", config.BaseApi)
+	output := c.Ctx.Output
+
+	output.SetStatus(200)
+	output.Header("Cache-Control", "no-cache")
+	output.Header("content-type", "text/javascript")
+	err := c.Ctx.Output.Body([]byte(js))
+	if err != nil {
+		logs.Error("config.js fail", err)
+		return
+	}
+}

+ 85 - 0
server/controllers/default.go

@@ -0,0 +1,85 @@
+package controllers
+
+import (
+	"github.com/astaxie/beego"
+	"github.com/astaxie/beego/logs"
+	"strconv"
+)
+
+type RespData struct {
+	Code int         `json:"code"`
+	Msg  string      `json:"msg"`
+	Data interface{} `json:"data"`
+}
+
+type BaseController struct {
+	beego.Controller
+	jsonData *RespData
+	// json real data
+	respData map[string]any
+}
+
+func (c *BaseController) json() {
+	c.checkJsonData()
+	if c.respData != nil {
+		c.jsonData.Data = c.respData
+	}
+	c.Data["json"] = c.jsonData
+	c.ServeJSON()
+}
+
+func (c *BaseController) ErrorJson(error error) {
+	c.setCode(-1).setMsg(error.Error()).json()
+}
+
+func (c *BaseController) checkJsonData() *BaseController {
+	data := c.jsonData
+	if data == nil {
+		data = &RespData{
+			Code: 0,
+			Msg:  "success",
+			Data: nil,
+		}
+		c.jsonData = data
+	}
+	return c
+}
+
+func (c *BaseController) setData(v interface{}) *BaseController {
+	c.checkJsonData()
+	c.jsonData.Data = v
+	return c
+}
+
+func (c *BaseController) addRespData(k string, v interface{}) *BaseController {
+	c.checkJsonData()
+	if c.respData == nil {
+		c.respData = map[string]any{}
+	}
+	c.respData[k] = v
+	return c
+}
+
+func (c *BaseController) setCode(code int) *BaseController {
+	c.checkJsonData()
+	c.jsonData.Code = code
+	return c
+}
+func (c *BaseController) setMsg(msg string) *BaseController {
+	c.checkJsonData()
+	c.jsonData.Msg = msg
+	return c
+}
+
+func (c *BaseController) getParam(k string) string {
+	return c.Ctx.Input.Param(k)
+}
+func (c *BaseController) getIntParam(k string) (int, error) {
+	idStr := c.Ctx.Input.Param(k)
+	id, err := strconv.Atoi(idStr)
+	logs.Info("id", id)
+	if err != nil {
+		return 0, err
+	}
+	return id, nil
+}

+ 173 - 0
server/controllers/file.go

@@ -0,0 +1,173 @@
+package controllers
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"github.com/astaxie/beego/logs"
+	"github.com/astaxie/beego/orm"
+	"os"
+	config2 "server/config"
+	"server/models"
+	nginx2 "server/nginx"
+	"server/utils"
+	"strings"
+	"time"
+)
+
+type FileController struct {
+	BaseController
+}
+
+func getRootDir() (string, error) {
+	root := fmt.Sprintf("%s/files", config2.GetDataDir())
+	if exist := utils.IsExist(root); exist != true {
+		err := os.MkdirAll(root, 0777)
+		if err != nil {
+			return "", err
+		}
+	}
+	return root, nil
+}
+
+// Get getAll
+func (c *FileController) Get() {
+	fileName := c.GetString("filename")
+	logs.Info("get file: {}", fileName)
+	root, err := getRootDir()
+	if err != nil {
+		c.Ctx.Output.SetStatus(404)
+		return
+	}
+
+	fromFile := fmt.Sprintf("%s/%s", root, fileName)
+	c.Ctx.Output.Download(fromFile, fileName)
+}
+
+// Post save certs
+func (c *FileController) Post() {
+	f, header, err := c.GetFile("file")
+	if err != nil {
+		c.Ctx.Output.SetStatus(500)
+		c.ErrorJson(err)
+		return
+	}
+
+	var req models.FileReq
+	err = c.ParseForm(&req)
+	if err != nil {
+		c.Ctx.Output.SetStatus(500)
+		c.ErrorJson(err)
+		return
+	}
+
+	defer f.Close()
+	root, err := getRootDir()
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	if strings.HasPrefix(req.Path, "/") {
+		req.Path = req.Path[1:len(req.Path)]
+	}
+	root = fmt.Sprintf("%s/%s", root, req.Key)
+	index := strings.LastIndex(req.Path, "/")
+	if index > 0 {
+		subDir := req.Path[0:index]
+		root = fmt.Sprintf("%s/%s", root, subDir)
+	}
+	if !utils.IsExist(root) {
+		err = os.MkdirAll(root, 0777)
+	}
+	if err != nil {
+		c.Ctx.Output.SetStatus(500)
+		c.ErrorJson(err)
+		return
+	}
+
+	toFile := fmt.Sprintf("%s/%s", root, header.Filename)
+	err = c.SaveToFile("file", toFile)
+	if err != nil {
+		c.Ctx.Output.SetStatus(500)
+		c.ErrorJson(err)
+		return
+	}
+	c.setData(toFile).json()
+}
+
+// Deploy 部署到服务器
+func (c *FileController) Deploy() {
+	var req models.DeployReq
+	err := json.Unmarshal(c.Ctx.Input.RequestBody, &req)
+	if err != nil {
+		logs.Error(err, string(c.Ctx.Input.RequestBody))
+		c.ErrorJson(err)
+		return
+	}
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	err = HandleDeploy(req)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	c.json()
+}
+
+func HandleDeploy(req models.DeployReq) error {
+	root, err := getRootDir()
+	if err != nil {
+		return err
+	}
+	root = fmt.Sprintf("%s/%s", root, req.Key)
+	if !utils.IsExist(root) {
+		logs.Warn("dir not exist: ", root)
+		return errors.New("未上传文件或者文件已被删除!")
+	}
+	o := orm.NewOrm()
+	nginx := models.Nginx{
+		Id: req.NginxId,
+	}
+	err = o.Read(&nginx)
+	if err != nil {
+		return err
+	}
+	ins := nginx2.GetInstance(&nginx)
+	dirs := ins.CheckDirs()
+	if nginx.IsLocal {
+		cmd := fmt.Sprintf("if [ ! -d %s ];then mkdir -p %s;fi && cp -r %s/* %s", req.Dir, req.Dir, root, req.Dir)
+		_, err := ins.Run(cmd)
+		return err
+	}
+
+	tarPath := fmt.Sprintf("%s.tar.gz", root)
+	if !utils.IsExist(tarPath) {
+		err = utils.TarXz(tarPath, root)
+		if err != nil {
+			return err
+		}
+	}
+
+	timeNow := time.Now().Format("20060102150105")
+	dst := fmt.Sprintf("%s/%s_%s.tar.gz", dirs.BackupDir, req.Key, timeNow)
+	err = ins.SendFile(tarPath, dst)
+	if err != nil {
+		return err
+	}
+	cmd := fmt.Sprintf("if [ ! -d %s ];then mkdir -p %s;fi && tar -zxvf %s -C %s", req.Dir, req.Dir, dst, req.Dir)
+	if req.Clear {
+		cmd = fmt.Sprintf("rm -rf %s;%s", req.Dir, cmd)
+	}
+	_, err = ins.Run(cmd)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+// Delete del certs
+func (c *FileController) Delete() {
+
+}

+ 250 - 0
server/controllers/nginx.go

@@ -0,0 +1,250 @@
+package controllers
+
+import (
+	"encoding/json"
+	"github.com/astaxie/beego/logs"
+	"github.com/astaxie/beego/orm"
+	"server/models"
+	ngx "server/nginx"
+	"strconv"
+)
+
+type NginxController struct {
+	BaseController
+}
+
+const ReplacePassword = "******"
+
+// Get getAll
+func (c *NginxController) Get() {
+	o := orm.NewOrm()
+	qs := o.QueryTable("nginx")
+	var list []*models.Nginx
+	_, err := qs.All(&list)
+	for i := range list {
+		item := list[i]
+		if item.Password != "" {
+			item.Password = ReplacePassword
+		}
+	}
+
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	} else {
+		c.setData(list).json()
+	}
+}
+
+// Post add nginx instance
+func (c *NginxController) Post() {
+	var nginx models.Nginx
+	err := json.Unmarshal(c.Ctx.Input.RequestBody, &nginx)
+	if err != nil {
+		logs.Error(err, string(c.Ctx.Input.RequestBody))
+		c.ErrorJson(err)
+		return
+	}
+	nginx.Check()
+	o := orm.NewOrm()
+	var saveErr error
+	if nginx.Id == 0 {
+		_, err = o.Insert(&nginx)
+		saveErr = err
+	} else {
+		tmp := models.Nginx{
+			Id: nginx.Id,
+		}
+		err = o.Read(&tmp)
+		if err != nil {
+			c.ErrorJson(err)
+			return
+		}
+		if nginx.Password == ReplacePassword {
+			nginx.Password = tmp.Password
+		}
+		nginx.HttpConf = tmp.HttpConf
+		_, err = o.Update(&nginx)
+		saveErr = err
+	}
+
+	if saveErr != nil {
+		c.ErrorJson(saveErr)
+		return
+	}
+	logs.Info("post", nginx)
+
+	instance := ngx.GetInstance(&nginx)
+	err = instance.Connect()
+	if err != nil {
+		c.setCode(1).setMsg(err.Error()).setData(nginx)
+		c.json()
+		return
+	}
+	out, err := instance.GetVersion()
+	if err != nil {
+		c.setCode(1).setMsg(err.Error()).setData(nginx)
+		c.json()
+		return
+	}
+	nginx.VersionInfo = out
+	_, _ = o.Update(&nginx, "VersionInfo")
+	c.setData(nginx).json()
+}
+
+// StartNginx startNginx
+func (c *NginxController) StartNginx() {
+	idStr := c.getParam(":id")
+	id, err := strconv.Atoi(idStr)
+	logs.Info("id", id)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	var nginx = models.Nginx{
+		Id: id,
+	}
+	o := orm.NewOrm()
+	err = o.Read(&nginx)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	instance := ngx.GetInstance(&nginx)
+	err = instance.Start()
+	isRun, msg := instance.Status()
+	c.setData(isRun).setMsg(msg).json()
+}
+
+// StopNginx add nginx instance
+func (c *NginxController) StopNginx() {
+	idStr := c.getParam(":id")
+	id, err := strconv.Atoi(idStr)
+	logs.Info("id", id)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+
+	var nginx = models.Nginx{
+		Id: id,
+	}
+	o := orm.NewOrm()
+	err = o.Read(&nginx)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	instance := ngx.GetInstance(&nginx)
+	err = instance.Stop()
+	isRun, msg := instance.Status()
+	c.setData(isRun).setMsg(msg).json()
+}
+
+// RefreshHttp nginx detail data
+func (c *NginxController) RefreshHttp() {
+	var nginx models.Nginx
+	err := json.Unmarshal(c.Ctx.Input.RequestBody, &nginx)
+	if err != nil {
+		logs.Error(err, string(c.Ctx.Input.RequestBody))
+		c.ErrorJson(err)
+		return
+	}
+
+	logs.Info("id", nginx)
+
+	o := orm.NewOrm()
+	if nginx.HttpConf != "" {
+		_, err = o.Update(&nginx, "HttpConf","HttpData")
+		if err != nil {
+			c.ErrorJson(err)
+			return
+		}
+	}
+	err = o.Read(&nginx)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	ins := ngx.GetInstance(&nginx)
+	err = ins.RefreshHttp(nginx)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	c.setData(nginx)
+	c.json()
+}
+
+// GetNginx nginx detail data
+func (c *NginxController) GetNginx() {
+	idStr := c.getParam(":id")
+	id, err := strconv.Atoi(idStr)
+	logs.Info("id", id)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	o := orm.NewOrm()
+
+	var nginx = models.Nginx{Id: id}
+	err = o.Read(&nginx)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	if nginx.Password != "" {
+		nginx.Password = ReplacePassword
+	}
+	c.addRespData("nginx", nginx)
+
+	var servers []models.ServerHost
+	_, err = o.QueryTable((*models.ServerHost)(nil)).Filter("NginxId", id).All(&servers)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	c.addRespData("servers", servers)
+	c.json()
+}
+
+// DelNginx delete a instance
+func (c *NginxController) DelNginx() {
+	id, err := c.getIntParam(":id")
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	o := orm.NewOrm()
+	nginx := models.Nginx{Id: id}
+	count, err := o.Delete(&nginx)
+	if err != nil {
+		c.ErrorJson(err)
+	} else {
+		c.setData(count).json()
+	}
+}
+
+// StopNginx add nginx instance
+func (c *NginxController) StatusNginx() {
+	idStr := c.getParam(":id")
+	id, err := strconv.Atoi(idStr)
+	logs.Info("id", id)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+
+	var nginx = models.Nginx{
+		Id: id,
+	}
+	o := orm.NewOrm()
+	err = o.Read(&nginx)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	instance := ngx.GetInstance(&nginx)
+	isRun, msg := instance.Status()
+	c.setData(isRun).setMsg(msg).json()
+}

+ 128 - 0
server/controllers/server.go

@@ -0,0 +1,128 @@
+package controllers
+
+import (
+	"encoding/json"
+	"github.com/astaxie/beego/orm"
+	"server/models"
+	nginx2 "server/nginx"
+)
+
+type ServerController struct {
+	BaseController
+}
+
+// Get getAllServers
+func (c *ServerController) Get() {
+
+	id, err := c.GetInt("id", 0)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+
+	o := orm.NewOrm()
+	server := models.ServerHost{Id: id}
+	err = o.Read(&server)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	c.setData(server).json()
+}
+
+// Post add or update nginx instance
+func (c *ServerController) Post() {
+	var server models.ServerHost
+	err := json.Unmarshal(c.Ctx.Input.RequestBody, &server)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	o := orm.NewOrm()
+	var saveErr error
+	if server.Id > 0 {
+		tmp := models.ServerHost{Id: server.Id}
+		err := o.Read(&tmp, "last_name")
+		if err == nil {
+			server.LastName = tmp.LastName
+			server.ServerConf = tmp.ServerConf
+		}
+		_, saveErr = o.Update(&server)
+	} else {
+		_, saveErr = o.Insert(&server)
+	}
+
+	if saveErr != nil {
+		c.ErrorJson(saveErr)
+	} else {
+		c.setData(server).json()
+	}
+}
+
+// Delete add or update nginx instance
+func (c *ServerController) Delete() {
+	var server models.ServerHost
+	err := json.Unmarshal(c.Ctx.Input.RequestBody, &server)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	o := orm.NewOrm()
+	err = o.Read(&server)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	nginx := models.Nginx{Id: server.NginxId}
+	err = o.Read(&nginx)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	ins := nginx2.GetInstance(&nginx)
+	server.Enable = false
+	err = ins.RefreshServer(server)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	delServer := models.ServerHost{Id: server.Id}
+	_, err = o.Delete(&delServer)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	c.setData("success").json()
+}
+
+// Refresh check and refresh to disk
+func (c *ServerController) Refresh() {
+	var postData models.ServerHost
+	err := json.Unmarshal(c.Ctx.Input.RequestBody, &postData)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	o := orm.NewOrm()
+	_, err = o.Update(&postData)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+
+	var nginx = models.Nginx{Id: postData.NginxId}
+	err = o.Read(&nginx)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	ins := nginx2.GetInstance(&nginx)
+	err = ins.RefreshServer(postData)
+	if err != nil {
+		c.ErrorJson(err)
+		return
+	}
+	postData.LastName = postData.Name
+	_, _ = o.Update(&postData)
+	c.setData(true).json()
+}

binární
server/data/db/sqlite.db


+ 27 - 0
server/db/db.go

@@ -0,0 +1,27 @@
+package db
+
+import (
+	"github.com/astaxie/beego"
+	"github.com/astaxie/beego/orm"
+	_ "github.com/mattn/go-sqlite3"
+	"server/models"
+	"time"
+)
+
+func Init() {
+	dir := beego.AppConfig.String("dbdir")
+	orm.RegisterDriver("sqlite3", orm.DRSqlite)
+	orm.RegisterDataBase("default", "sqlite3", dir+"/sqlite.db")
+	orm.SetMaxIdleConns("default", 50)
+	orm.SetMaxOpenConns("default", 200)
+
+	//设置数据库时区
+	orm.DefaultTimeLoc = time.Local
+
+	orm.RegisterModel(new(models.Nginx))
+	orm.RegisterModel(new(models.ServerHost))
+	orm.RegisterModel(new(models.NginxCerts))
+
+	orm.RunSyncdb("default", false, true)
+
+}

+ 43 - 0
server/go.mod

@@ -0,0 +1,43 @@
+module server
+
+go 1.18
+
+require github.com/astaxie/beego v1.12.1
+
+require (
+	github.com/mattn/go-sqlite3 v1.14.17
+	github.com/mholt/archiver/v4 v4.0.0-alpha.8
+	github.com/pkg/sftp v1.13.5
+	github.com/smartystreets/goconvey v1.6.4
+	golang.org/x/crypto v0.10.0
+)
+
+require (
+	github.com/andybalholm/brotli v1.0.4 // indirect
+	github.com/bodgit/plumbing v1.2.0 // indirect
+	github.com/bodgit/sevenzip v1.3.0 // indirect
+	github.com/bodgit/windows v1.0.0 // indirect
+	github.com/connesc/cipherio v0.2.1 // indirect
+	github.com/dsnet/compress v0.0.1 // indirect
+	github.com/golang/snappy v0.0.4 // indirect
+	github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 // indirect
+	github.com/hashicorp/errwrap v1.0.0 // indirect
+	github.com/hashicorp/go-multierror v1.1.1 // indirect
+	github.com/jtolds/gls v4.20.0+incompatible // indirect
+	github.com/klauspost/compress v1.15.9 // indirect
+	github.com/klauspost/pgzip v1.2.5 // indirect
+	github.com/kr/fs v0.1.0 // indirect
+	github.com/nwaples/rardecode/v2 v2.0.0-beta.2 // indirect
+	github.com/pierrec/lz4/v4 v4.1.15 // indirect
+	github.com/shiena/ansicolor v0.0.0-20230509054315-a9deabde6e02 // indirect
+	github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d // indirect
+	github.com/therootcompany/xz v1.0.1 // indirect
+	github.com/ulikunitz/xz v0.5.10 // indirect
+	go4.org v0.0.0-20200411211856-f5505b9728dd // indirect
+	golang.org/x/net v0.10.0 // indirect
+	golang.org/x/sys v0.9.0 // indirect
+	golang.org/x/text v0.10.0 // indirect
+	google.golang.org/appengine v1.6.7 // indirect
+	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
+	gopkg.in/yaml.v2 v2.2.8 // indirect
+)

+ 342 - 0
server/go.sum

@@ -0,0 +1,342 @@
+cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
+cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
+cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
+cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
+cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
+cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
+cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
+cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
+cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
+cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
+cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
+cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
+cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
+cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
+dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
+github.com/Knetic/govaluate v3.0.0+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
+github.com/OwnLocal/goes v1.0.0/go.mod h1:8rIFjBGTue3lCU0wplczcUgt9Gxgrkkrw7etMIcn8TM=
+github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
+github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
+github.com/astaxie/beego v1.12.1 h1:dfpuoxpzLVgclveAXe4PyNKqkzgm5zF4tgF2B3kkM2I=
+github.com/astaxie/beego v1.12.1/go.mod h1:kPBWpSANNbSdIqOc8SUL9h+1oyBMZhROeYsXQDbidWQ=
+github.com/beego/goyaml2 v0.0.0-20130207012346-5545475820dd/go.mod h1:1b+Y/CofkYwXMUU0OhQqGvsY2Bvgr4j6jfT699wyZKQ=
+github.com/beego/x2j v0.0.0-20131220205130-a0352aadc542/go.mod h1:kSeGC/p1AbBiEp5kat81+DSQrZenVBZXklMLaELspWU=
+github.com/bodgit/plumbing v1.2.0 h1:gg4haxoKphLjml+tgnecR4yLBV5zo4HAZGCtAh3xCzM=
+github.com/bodgit/plumbing v1.2.0/go.mod h1:b9TeRi7Hvc6Y05rjm8VML3+47n4XTZPtQ/5ghqic2n8=
+github.com/bodgit/sevenzip v1.3.0 h1:1ljgELgtHqvgIp8W8kgeEGHIWP4ch3xGI8uOBZgLVKY=
+github.com/bodgit/sevenzip v1.3.0/go.mod h1:omwNcgZTEooWM8gA/IJ2Nk/+ZQ94+GsytRzOJJ8FBlM=
+github.com/bodgit/windows v1.0.0 h1:rLQ/XjsleZvx4fR1tB/UxQrK+SJ2OFHzfPjLWWOhDIA=
+github.com/bodgit/windows v1.0.0/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
+github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60=
+github.com/casbin/casbin v1.7.0/go.mod h1:c67qKN6Oum3UF5Q1+BByfFxkwKvhwW57ITjqwtzR1KE=
+github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
+github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
+github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58/go.mod h1:EOBUe0h4xcZ5GoxqC5SDxFQ8gwyZPKQoEzownBlhI80=
+github.com/connesc/cipherio v0.2.1 h1:FGtpTPMbKNNWByNrr9aEBtaJtXjqOzkIXNYJp6OEycw=
+github.com/connesc/cipherio v0.2.1/go.mod h1:ukY0MWJDFnJEbXMQtOcn2VmTpRfzcTz4OoVrWGGJZcA=
+github.com/couchbase/go-couchbase v0.0.0-20181122212707-3e9b6e1258bb/go.mod h1:TWI8EKQMs5u5jLKW/tsb9VwauIrMIxQG1r5fMsswK5U=
+github.com/couchbase/gomemcached v0.0.0-20181122193126-5125a94a666c/go.mod h1:srVSlQLB8iXBVXHgnqemxUXqN6FCvClgCMPCsjBDR7c=
+github.com/couchbase/goutils v0.0.0-20180530154633-e865a1461c8a/go.mod h1:BQwMFlJzDjFDG3DJUdU0KORxn88UlsOULuxLExMh3Hs=
+github.com/cupcake/rdb v0.0.0-20161107195141-43ba34106c76/go.mod h1:vYwsqCOLxGiisLwp9rITslkFNpZD5rz43tf41QFkTWY=
+github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q=
+github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo=
+github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
+github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
+github.com/elazarl/go-bindata-assetfs v1.0.0 h1:G/bYguwHIzWq9ZoyUQqrjTmJbbYn3j3CKKpKinvZLFk=
+github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
+github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-redis/redis v6.14.2+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
+github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
+github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
+github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
+github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
+github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
+github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.3 h1:GV+pQPG/EUUbkh47niozDcADz6go/dUwhVzdUQHIVRw=
+github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
+github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
+github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
+github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
+github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
+github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
+github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
+github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
+github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
+github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
+github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
+github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
+github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
+github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
+github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
+github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
+github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
+github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
+github.com/klauspost/pgzip v1.2.5 h1:qnWYvvKqedOF2ulHpMG72XQol4ILEJ8k2wwRl/Km8oE=
+github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
+github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
+github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
+github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
+github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
+github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
+github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
+github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+github.com/mholt/archiver/v4 v4.0.0-alpha.8 h1:tRGQuDVPh66WCOelqe6LIGh0gwmfwxUrSSDunscGsRM=
+github.com/mholt/archiver/v4 v4.0.0-alpha.8/go.mod h1:5f7FUYGXdJWUjESffJaYR4R60VhnHxb2X3T1teMyv5A=
+github.com/nwaples/rardecode/v2 v2.0.0-beta.2 h1:e3mzJFJs4k83GXBEiTaQ5HgSc/kOK8q0rDaRO0MPaOk=
+github.com/nwaples/rardecode/v2 v2.0.0-beta.2/go.mod h1:yntwv/HfMc/Hbvtq9I19D1n58te3h6KsqCf3GxyfBGY=
+github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
+github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
+github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
+github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/sftp v1.13.5 h1:a3RLUqkyjYRtBTZJZ1VRrKbN3zhuPLlUc3sphVz81go=
+github.com/pkg/sftp v1.13.5/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg=
+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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
+github.com/shiena/ansicolor v0.0.0-20230509054315-a9deabde6e02 h1:v9ezJDHA1XGxViAUSIoO/Id7Fl63u6d0YmsAm+/p2hs=
+github.com/shiena/ansicolor v0.0.0-20230509054315-a9deabde6e02/go.mod h1:RF16/A3L0xSa0oSERcnhd8Pu3IXSDZSK2gmGIMsttFE=
+github.com/siddontang/go v0.0.0-20180604090527-bdc77568d726/go.mod h1:3yhqj7WBBfRhbBlzyOC3gUxftwsU0u8gqevxwIHQpMw=
+github.com/siddontang/ledisdb v0.0.0-20181029004158-becf5f38d373/go.mod h1:mF1DpOSOUiJRMR+FDqaqu3EBqrybQtrDDszLUZ6oxPg=
+github.com/siddontang/rdb v0.0.0-20150307021120-fc89ed2e418d/go.mod h1:AMEsy7v5z92TR1JKMkLLoaOQk++LVnOKL3ScbJ8GNGA=
+github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
+github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
+github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
+github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
+github.com/ssdb/gossdb v0.0.0-20180723034631-88f6b59b84ec/go.mod h1:QBvMkMya+gXctz3kmljlUCu/yB3GZ6oee+dUozsezQE=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/syndtr/goleveldb v0.0.0-20181127023241-353a9fca669c/go.mod h1:Z4AUp2Km+PwemOoO/VB5AOx9XSsIItzFjoJlOSiYmn0=
+github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw=
+github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY=
+github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8=
+github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8=
+github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
+github.com/wendal/errors v0.0.0-20130201093226-f66c77a7882b/go.mod h1:Q12BUT7DqIlHRmgv3RskH+UCM/4eqVMgI0EMmlSpAXc=
+go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
+go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
+go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go4.org v0.0.0-20200411211856-f5505b9728dd h1:BNJlw5kRTzdmyfh5U8F93HA2OwkP7ZGwA51eJ/0wKOU=
+go4.org v0.0.0-20200411211856-f5505b9728dd/go.mod h1:CIiUVy99QCPfoE13bO4EZaz5GZMZXMSBGhxRdsvzbkg=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM=
+golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
+golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
+golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
+golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
+golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
+golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
+golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
+golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
+golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
+golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
+golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
+golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
+golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
+golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28=
+golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58=
+golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200117065230-39095c1d176c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
+google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
+google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
+google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
+google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
+google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
+google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
+google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
+google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
+google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
+google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
+google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+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=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
+rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
+rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
+rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

+ 15 - 0
server/main.go

@@ -0,0 +1,15 @@
+package main
+
+import (
+	"github.com/astaxie/beego"
+	_ "server/config"
+	"server/db"
+	_ "server/routers"
+)
+
+func main() {
+
+	db.Init()
+	beego.Run()
+
+}

+ 16 - 0
server/models/file.go

@@ -0,0 +1,16 @@
+package models
+
+type FileReq struct {
+	Path string `json:"path"`
+	Key  string `json:"key"`
+}
+
+type DeployReq struct {
+	// 已上传的文件夹根目录
+	Key     string `json:"key"`
+	NginxId int    `json:"nginxId"`
+	// 部署目录
+	Dir string `json:"dir"`
+	// 是否先清空文件夹,再部署
+	Clear bool `json:"clear"`
+}

+ 73 - 0
server/models/nginx.go

@@ -0,0 +1,73 @@
+package models
+
+import "github.com/astaxie/beego"
+
+// Nginx nginx data
+type Nginx struct {
+	Id          int    `orm:"pk;auto" json:"id"`
+	Name        string `json:"name"`
+	Uid         string `json:"uid"`
+	VersionInfo string `json:"versionInfo"`
+	// 是否以服务的形式进行托管
+	IsServer bool `json:"isServer"`
+
+	NginxPath string `json:"nginxPath"`
+	// nginx的配置文件所在目录,即nginx.conf所在的目录
+	NginxDir string `json:"nginxDir"`
+	// 数据目录,所有的配置文件目录
+	DataDir  string `json:"dataDir"`
+	IsLocal  bool   `json:"isLocal"`
+	IpAddr   string `json:"ipAddr"`
+	Port     int    `json:"port"`
+	User     string `json:"user"`
+	Password string `json:"password"`
+	HttpData string `json:"httpData"`
+	HttpConf string `json:"httpConf"`
+	Remark   string `json:"remark"`
+}
+
+// Check 检查参数,给默认值
+func (t *Nginx) Check() {
+	if t.DataDir == "" {
+		t.DataDir = beego.AppConfig.String("datadir")
+	}
+	if t.NginxPath == "" {
+		t.NginxPath = beego.AppConfig.String("nginxPath")
+	}
+	if t.NginxDir == "" {
+		t.NginxDir = beego.AppConfig.String("nginxDir")
+	}
+}
+
+// ServerHost nginx data
+type ServerHost struct {
+	Id     int  `orm:"pk;auto" json:"id"`
+	Enable bool `json:"enable"`
+	// is tcp or udp, default is false
+	IsStream bool   `json:"isStream"`
+	NginxId  int    `json:"nginxId"`
+	Name     string `json:"name"`
+	// 记录一下上一次刷新保存的名字
+	LastName string `json:"lastName"`
+	// 前端完整的 server_host配置数据
+	ServerData string `json:"serverData"`
+
+	// nginx server.conf content
+	ServerConf string `json:"serverConf"`
+	Remark     string `json:"remark"`
+}
+
+// NginxCerts nginx证书, ServiceName域名,唯一不可重复
+type NginxCerts struct {
+	Id          int    `orm:"pk;auto" json:"id"`
+	ServiceName string `orm:"unique" json:"serviceName"`
+	Key         string `json:"key"`
+	Pem         string `json:"pem"`
+	NginxId     int    `json:"nginxId"`
+	ExpiresAt   string `json:"expiresAt"`
+	SubjectName string `json:"subjectName"`
+	// 系统的提示信息
+	HintMsg   string `json:"hintMsg"`
+	CreatedAt string `json:"createdAt"`
+	Remark    string `json:"remark"`
+}

+ 285 - 0
server/nginx/instance.go

@@ -0,0 +1,285 @@
+package nginx
+
+import (
+	"errors"
+	"fmt"
+	"github.com/astaxie/beego/logs"
+	"server/models"
+	"strings"
+	"time"
+)
+
+var logger = logs.GetLogger()
+
+type Dirs struct {
+	DataDir   string
+	ConfDir   string
+	StreamDir string
+	CertsDir  string
+	BackupDir string
+}
+
+type InstanceInter interface {
+	Connect() error
+	Close(onlySession bool)
+	Run(cmd string) (string, error)
+	SetNginx(nginx *models.Nginx)
+	SendFile(src string, remote string) error
+}
+
+type Instance struct {
+	InstanceInter
+	nginx *models.Nginx
+}
+
+func (n *Instance) CheckDirs() Dirs {
+	nginx := n.nginx
+	dataDir := nginx.DataDir
+	if strings.HasSuffix(dataDir, "/") {
+		dataDir = dataDir[0 : len(dataDir)-1]
+	}
+	streamDir := fmt.Sprintf("%s/stream.d", dataDir)
+	certsDir := fmt.Sprintf("%s/certs", dataDir)
+	backupDir := fmt.Sprintf("%s/backup", dataDir)
+	confDir := fmt.Sprintf("%s/conf.d", dataDir)
+	_, _ = n.Run(fmt.Sprintf("mkdir -p %s %s %s %s", confDir, streamDir, certsDir, backupDir))
+	return Dirs{
+		DataDir:   dataDir,
+		ConfDir:   confDir,
+		StreamDir: streamDir,
+		CertsDir:  certsDir,
+		BackupDir: backupDir,
+	}
+}
+
+func (n *Instance) RefreshServer(server models.ServerHost) error {
+	dirs := n.CheckDirs()
+	var confDir string
+	if server.IsStream {
+		confDir = dirs.StreamDir
+	} else {
+		confDir = dirs.ConfDir
+	}
+	// id_server_name.conf
+	realName := fmt.Sprintf("%s/%d_%s.conf", confDir, server.Id, server.Name)
+	var lastName string
+	if server.LastName != "" {
+		lastName = fmt.Sprintf("%s/%d_%s.conf", confDir, server.Id, server.LastName)
+	} else {
+		lastName = fmt.Sprintf("%s/%d_%s.conf", confDir, server.Id, server.Name)
+	}
+	backName := fmt.Sprintf("%s/%s.conf_%s", dirs.BackupDir, server.Name, time.Now().Format("20060102150405"))
+
+	res, err := n.Run(fmt.Sprintf("if [ -f %s ];then mv -f %s %s;fi;rm -rf %s/%d_*.conf", lastName, lastName, backName, confDir, server.Id))
+	if err != nil {
+		return err
+	}
+
+	defer n.Close(true)
+	if !server.Enable {
+		return nil
+	}
+	serverConf := strings.ReplaceAll(server.ServerConf, "\"", "\\\"")
+	serverConf = strings.ReplaceAll(serverConf, "$", "\\$")
+	cmd := fmt.Sprintf("echo \"%s\" > %s", serverConf, realName)
+	res, err = n.Run(cmd)
+	if err != nil {
+		logger.Printf("echo conf fail", err, res)
+		return err
+	}
+	if err := n.Check(); err != nil {
+		_, recoveryErr := n.Run(fmt.Sprintf("if [ -f %s ];then mv -f %s %s;fi", backName, backName, realName))
+		if recoveryErr != nil {
+			return errors.New(fmt.Sprintf("配置文件异常: %s;且文件恢复异常,请手动处理或者修正后重新刷新!", err.Error()))
+		}
+		return err
+	}
+	if err := n.Reload(); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (n *Instance) RefreshHttp(nginx models.Nginx) error {
+	dirs := n.CheckDirs()
+	var confDir = nginx.NginxDir
+
+	realName := fmt.Sprintf("%s/nginx.conf", dirs.DataDir)
+	linkName := fmt.Sprintf("%s/nginx.conf", confDir)
+	backName := fmt.Sprintf("%s/%d.http_%s", dirs.BackupDir, nginx.Id, time.Now().Format("060102150405"))
+
+	res, err := n.Run(fmt.Sprintf("find %s -name nginx.conf -type l -delete", confDir))
+	if err != nil {
+		logger.Printf("echo rm conf file fail", err, res)
+		return err
+	}
+	// 如果非软连接,就先mv 备份
+	res, err = n.Run(fmt.Sprintf("cd %s && if [ -f nginx.conf ];then mv -f nginx.conf nginx.conf.bak ;fi", confDir))
+	defer n.Close(true)
+
+	httpConf := strings.ReplaceAll(nginx.HttpConf, "\"", "\\\"")
+	httpConf = strings.ReplaceAll(httpConf, "$", "\\$")
+	cmd := fmt.Sprintf("echo \"%s\" > %s && cp %s %s", httpConf, realName, realName, backName)
+	res, err = n.Run(cmd)
+	if err != nil {
+		logger.Printf("echo conf fail", err, res)
+		return err
+	}
+	res, err = n.Run(fmt.Sprintf("ln -s %s %s", realName, linkName))
+	if err != nil {
+		return err
+	}
+	if err := n.Check(); err != nil {
+		return err
+	}
+	if err := n.Reload(); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (n *Instance) GetVersion() (string, error) {
+	out, err := n.Run(fmt.Sprintf("%s -V", n.nginx.NginxPath))
+	if err != nil {
+		logs.Warn("CheckConf", err)
+		return "", err
+	}
+	logs.Info("CheckConf", out)
+	return out, err
+}
+
+func (n *Instance) Check() error {
+	nginx := n.nginx
+	_ = n.CheckDirs()
+	out, err := n.Run(fmt.Sprintf("%s -t", nginx.NginxPath))
+	if err != nil {
+		logs.Warn("CheckConf fail", err, out)
+		return err
+	}
+	logs.Info("CheckConf", out)
+	return nil
+}
+
+// Reload 如果nginx未启动,这个返回码为 1
+func (n *Instance) Reload() error {
+	isRun, _ := n.Status()
+	if !isRun {
+		logs.Info("nginx not running")
+		return nil
+	}
+	var cmd string
+	if n.nginx.IsServer {
+		cmd = "service nginx reload"
+	} else {
+		cmd = fmt.Sprintf("%s -s reload", n.nginx.NginxPath)
+	}
+	out, err := n.Run(cmd)
+	if err != nil {
+		logs.Warn("Reload", err)
+		return err
+	}
+	logs.Info("Reload", out)
+	return nil
+}
+
+func (n *Instance) Status() (bool, string) {
+
+	var cmd string
+	if n.nginx.IsServer {
+		cmd = "service nginx status"
+	} else { // 得想其它办法,这个没有直接的命令,ps也不见得有
+		cmd = fmt.Sprintf("%s -s reload", n.nginx.NginxPath)
+	}
+	out, err := n.Run(cmd)
+	if err != nil {
+		logs.Warn("Status", err)
+		return false, out
+	}
+	logs.Info("Status", out)
+	return true, out
+}
+
+// Start 如果已经启动,这里会返回-1
+func (n *Instance) Start() error {
+	var cmd string
+	if n.nginx.IsServer {
+		cmd = "service nginx start"
+	} else {
+		cmd = fmt.Sprintf("%s", n.nginx.NginxPath)
+	}
+	go func() {
+		out, err := n.Run(cmd)
+		if err != nil {
+			logs.Warn("Start", err)
+		}
+		logs.Info("Start", out)
+	}()
+	time.Sleep(time.Second * 5)
+	return nil
+}
+
+func (n *Instance) Stop() error {
+	var cmd string
+	if n.nginx.IsServer {
+		cmd = "service nginx stop"
+	} else {
+		cmd = fmt.Sprintf("%s -s stop", n.nginx.NginxPath)
+	}
+	out, err := n.Run(cmd)
+	if err != nil {
+		logs.Warn("Stop", err)
+		return err
+	}
+	logs.Info("Stop", out)
+	return nil
+}
+
+func (n *Instance) GetCerts() string {
+	dirs := n.CheckDirs()
+	cmd := fmt.Sprintf("cd %s && /usr/bin/ls -l *.key | /usr/bin/awk '{print $9}'", dirs.CertsDir)
+	out, err := n.Run(cmd)
+	if err != nil {
+		logs.Warn("GetCerts", err)
+		return ""
+	}
+	logs.Info("GetCerts", out)
+	return out
+}
+
+func (n *Instance) GetCertData(name string) (*models.NginxCerts, error) {
+	cert := models.NginxCerts{
+		ServiceName: name,
+	}
+	dirs := n.CheckDirs()
+	pemPath := fmt.Sprintf("%s/%s.pem", dirs.CertsDir, name)
+	keyPath := fmt.Sprintf("%s/%s.key", dirs.CertsDir, name)
+	cmd := "/usr/bin/cat " + keyPath
+	out, err := n.Run(cmd)
+	if err != nil {
+		logs.Warn("Stop", err)
+		return &cert, err
+	}
+	cert.Key = out
+	cmd = "/usr/bin/cat " + pemPath
+	out, err = n.Run(cmd)
+	if err != nil {
+		logs.Warn("Stop", err)
+		return &cert, err
+	}
+	cert.Pem = out
+	return &cert, nil
+}
+
+func (n *Instance) SaveCerts(cert *models.NginxCerts) error {
+	dirs := n.CheckDirs()
+	pemPath := fmt.Sprintf("%s/%s.pem", dirs.CertsDir, cert.ServiceName)
+	keyPath := fmt.Sprintf("%s/%s.key", dirs.CertsDir, cert.ServiceName)
+	cmd := fmt.Sprintf("echo '%s' > %s;echo '%s' > %s", cert.Pem, pemPath, cert.Key, keyPath)
+	out, err := n.Run(cmd)
+	if err != nil {
+		logs.Warn("SaveCerts", err)
+		return err
+	}
+	logs.Info("SaveCerts ", out)
+	return nil
+}

+ 66 - 0
server/nginx/local.go

@@ -0,0 +1,66 @@
+package nginx
+
+import (
+	"errors"
+	"fmt"
+	"github.com/astaxie/beego/logs"
+	"io"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"server/models"
+	"server/utils"
+)
+
+// LocalInstance 本地实例
+type LocalInstance struct {
+	nginx *models.Nginx
+}
+
+func (n *LocalInstance) Connect() error {
+	return nil
+}
+
+func (n *LocalInstance) Close(onlySession bool) {
+
+}
+func (n *LocalInstance) SetNginx(nginx *models.Nginx) {
+	n.nginx = nginx
+}
+
+func (n *LocalInstance) Run(cmd string) (string, error) {
+	logs.Info("Run: ", cmd)
+	command := exec.Command("/usr/bin/sh", "-c", cmd)
+	out, err := command.CombinedOutput()
+	if err != nil {
+		logs.Warn("local run cmd fail", err, string(out))
+		msg := fmt.Sprintf("%s;\n%s", err.Error(), string(out))
+		return string(out), errors.New(msg)
+	}
+	logs.Info("Run resp", string(out))
+	return string(out), nil
+}
+
+// SendFile Local 就是copy文件了
+func (n *LocalInstance) SendFile(src string, remote string) error {
+	srcFile, err := os.Open(src)
+	if err != nil {
+		return err
+	}
+	defer srcFile.Close()
+	base := filepath.Base(remote)
+	if !utils.IsExist(base) {
+		err = os.MkdirAll(base, 0777)
+	}
+	if err != nil {
+		return err
+	}
+	dst, err := os.OpenFile(remote, os.O_CREATE|os.O_WRONLY, 0777)
+	if err != nil {
+		return err
+	}
+	defer dst.Close()
+
+	_, err = io.Copy(dst, srcFile)
+	return err
+}

+ 40 - 0
server/nginx/manager.go

@@ -0,0 +1,40 @@
+package nginx
+
+import (
+	"server/models"
+)
+
+var INSTANCES = map[int]*Instance{}
+
+func GetInstance(nginx *models.Nginx) *Instance {
+	var instance *Instance = INSTANCES[nginx.Id]
+	if instance != nil {
+		old := instance.nginx
+		if old.IpAddr != nginx.IpAddr || old.Port != nginx.Port || old.User != nginx.User || old.Password != nginx.Password {
+			instance.Close(false)
+			instance = nil
+			INSTANCES[nginx.Id] = nil
+		} else {
+			instance.nginx = nginx
+			instance.SetNginx(nginx)
+			return instance
+		}
+	}
+	if nginx.IsLocal {
+		instance = &Instance{
+			&LocalInstance{
+				nginx: nginx,
+			},
+			nginx,
+		}
+	} else {
+		instance = &Instance{
+			&RemoteInstance{
+				nginx: nginx,
+			},
+			nginx,
+		}
+	}
+	INSTANCES[nginx.Id] = instance
+	return instance
+}

+ 127 - 0
server/nginx/remote.go

@@ -0,0 +1,127 @@
+package nginx
+
+import (
+	"errors"
+	"fmt"
+	"github.com/astaxie/beego/logs"
+	"github.com/pkg/sftp"
+	"golang.org/x/crypto/ssh"
+	"io"
+	"net"
+	"os"
+	"server/models"
+	"time"
+)
+
+// RemoteInstance 远程
+type RemoteInstance struct {
+	nginx      *models.Nginx
+	client     *ssh.Client
+	LastResult string
+}
+
+func (n *RemoteInstance) Connect() error {
+	if n.client != nil {
+		return nil
+	}
+	config := ssh.ClientConfig{}
+	config.SetDefaults()
+	config.User = n.nginx.User
+	config.Auth = []ssh.AuthMethod{
+		ssh.Password(n.nginx.Password),
+	}
+	config.HostKeyCallback = func(hostname string, remote net.Addr, key ssh.PublicKey) error {
+		return nil
+	}
+	config.Timeout = time.Second * 60
+	addr := fmt.Sprintf("%s:%d", n.nginx.IpAddr, n.nginx.Port)
+	client, err := ssh.Dial("tcp", addr, &config)
+	if err != nil {
+		logs.Warn("connect error", err)
+		return err
+	}
+	n.client = client
+	go n.onDisConnect()
+	return nil
+}
+
+// Run RemoteInstance 这里应该要处理session断开的流程吧
+func (n *RemoteInstance) Run(cmd string) (string, error) {
+	logs.Info("Run: ", cmd)
+	if n.client == nil {
+		err := n.Connect()
+		if err != nil {
+			return "", err
+		}
+	}
+	session, err := n.client.NewSession()
+	if err != nil {
+		logs.Warn("NewSession fail", err)
+		return "", err
+	}
+	defer session.Close()
+	buf, err := session.CombinedOutput(cmd)
+	n.LastResult = string(buf)
+	logger.Printf("out: %v", n.LastResult)
+	if err != nil {
+		err = errors.New(fmt.Sprintf("%s;\n%s", err.Error(), n.LastResult))
+	}
+	return n.LastResult, err
+}
+
+func (n *RemoteInstance) Close(onlySession bool) {
+	if onlySession {
+		return
+	}
+	if n.client != nil {
+		n.client.Close()
+		n.client = nil
+	}
+}
+
+func (n *RemoteInstance) onDisConnect() {
+	if n.client != nil {
+		err := n.client.Wait()
+		logger.Printf("disconnect", err)
+	}
+	n.client = nil
+}
+func (n *RemoteInstance) SetNginx(nginx *models.Nginx) {
+	n.nginx = nginx
+}
+
+func (n *RemoteInstance) getSSHConfig() ssh.ClientConfig {
+	config := ssh.ClientConfig{}
+	config.SetDefaults()
+	config.User = n.nginx.User
+	config.Auth = []ssh.AuthMethod{
+		ssh.Password(n.nginx.Password),
+	}
+	config.HostKeyCallback = func(hostname string, remote net.Addr, key ssh.PublicKey) error {
+		return nil
+	}
+	config.Timeout = time.Second * 60
+	return config
+}
+
+// SendFile RemoteInstance 这里应该要处理session断开的流程吧
+func (n *RemoteInstance) SendFile(src string, remote string) error {
+	session, err := sftp.NewClient(n.client)
+	if err != nil {
+		return err
+	}
+	defer session.Close()
+	srcFile, err := os.Open(src)
+	if err != nil {
+		return err
+	}
+	defer srcFile.Close()
+	dstFile, err := session.Create(remote)
+	if err != nil {
+		return err
+	}
+	defer dstFile.Close()
+	l, err := io.Copy(dstFile, srcFile)
+	logs.Info("sendFile ok with size: ", l)
+	return err
+}

+ 62 - 0
server/routers/router.go

@@ -0,0 +1,62 @@
+package routers
+
+import (
+	"encoding/json"
+	"github.com/astaxie/beego"
+	"github.com/astaxie/beego/context"
+	"github.com/astaxie/beego/logs"
+	"net/http"
+	config2 "server/config"
+	"server/controllers"
+	"strings"
+)
+
+func init() {
+	config := config2.Config
+	ns := beego.NewNamespace(config.BaseApi,
+		beego.NSRouter("/nginx", &controllers.NginxController{}),
+		beego.NSRouter("/nginx/:id", &controllers.NginxController{}, "get:GetNginx"),
+		beego.NSRouter("/nginx/:id", &controllers.NginxController{}, "delete:DelNginx"),
+		beego.NSRouter("/http/refresh", &controllers.NginxController{}, "post:RefreshHttp"),
+		beego.NSRouter("/nginx/:id/start", &controllers.NginxController{}, "post:StartNginx"),
+		beego.NSRouter("/nginx/:id/stop", &controllers.NginxController{}, "post:StopNginx"),
+		beego.NSRouter("/nginx/:id/status", &controllers.NginxController{}, "post:StatusNginx"),
+		// certs
+		beego.NSRouter("/nginx/:id/certs", &controllers.CertController{}),
+		beego.NSRouter("/nginx/:id/certs/sync", &controllers.CertController{}, "post:Sync"),
+		// nginx server apis
+		beego.NSRouter("/server", &controllers.ServerController{}),
+		beego.NSRouter("/server/refresh", &controllers.ServerController{}, "post:Refresh"),
+		// file upload download
+		beego.NSRouter("/file", &controllers.FileController{}),
+		beego.NSRouter("/file/deploy", &controllers.FileController{}, "post:Deploy"),
+	)
+	beego.AddNamespace(ns)
+
+	beego.Router("/nginx-ui/config.js", &controllers.ConfigController{})
+	// portal static assets
+	beego.SetStaticPath("/nginx-ui", "static/web")
+	beego.SetStaticPath("/web", "static/web")
+
+	beego.Get("/", func(ctx *context.Context) {
+		ctx.Redirect(301, "/nginx-ui/index.html")
+	})
+
+	beego.ErrorHandler("404", func(writer http.ResponseWriter, request *http.Request) {
+		accept := request.Header.Get("accept")
+		logs.Warn("404", accept)
+		if strings.Contains(accept, "json") {
+			writer.Header().Set("content-type", "application/json")
+			writer.WriteHeader(200)
+			resp := controllers.RespData{
+				Code: -2,
+				Msg:  "server error",
+			}
+			str, _ := json.Marshal(&resp)
+			writer.Write(str)
+		} else {
+			writer.WriteHeader(404)
+			writer.Write([]byte(""))
+		}
+	})
+}

+ 0 - 0
server/static/web/web


+ 39 - 0
server/tests/default_test.go

@@ -0,0 +1,39 @@
+package test
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"runtime"
+	"path/filepath"
+	_ "server/routers"
+
+	"github.com/astaxie/beego"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func init() {
+	_, file, _, _ := runtime.Caller(0)
+	apppath, _ := filepath.Abs(filepath.Dir(filepath.Join(file, ".." + string(filepath.Separator))))
+	beego.TestBeegoInit(apppath)
+}
+
+
+// TestBeego is a sample to run an endpoint test
+func TestBeego(t *testing.T) {
+	r, _ := http.NewRequest("GET", "/", nil)
+	w := httptest.NewRecorder()
+	beego.BeeApp.Handlers.ServeHTTP(w, r)
+
+	beego.Trace("testing", "TestBeego", "Code[%d]\n%s", w.Code, w.Body.String())
+
+	Convey("Subject: Test Station Endpoint\n", t, func() {
+	        Convey("Status Code Should Be 200", func() {
+	                So(w.Code, ShouldEqual, 200)
+	        })
+	        Convey("The Result Should Not Be Empty", func() {
+	                So(w.Body.Len(), ShouldBeGreaterThan, 0)
+	        })
+	})
+}
+

+ 12 - 0
server/utils/cert.go

@@ -0,0 +1,12 @@
+package utils
+
+import (
+	"crypto/x509"
+	"encoding/pem"
+)
+
+func CheckHttps(pub string) (*x509.Certificate, error) {
+	skBlock, _ := pem.Decode([]byte(pub))
+	cert, err := x509.ParseCertificate(skBlock.Bytes)
+	return cert, err
+}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 8 - 0
server/utils/cert_test.go


+ 54 - 0
server/utils/file.go

@@ -0,0 +1,54 @@
+package utils
+
+import (
+	"context"
+	"github.com/mholt/archiver/v4"
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+func IsExist(path string) bool {
+	_, err := os.Stat(path)
+	if os.IsNotExist(err) {
+		return false
+	}
+	return true
+}
+
+func TarXz(dst string, src string) error {
+	src = filepath.Clean(src)
+	dst = filepath.Clean(dst)
+	if !strings.HasSuffix(src, string(os.PathSeparator)) {
+		src += string(os.PathSeparator)
+	}
+	files, err := archiver.FilesFromDisk(nil, map[string]string{
+		src: "",
+	})
+	if err != nil {
+		return err
+	}
+
+	out, err := os.Create(dst)
+	if err != nil {
+		return err
+	}
+	defer out.Close()
+	var compression archiver.Compression
+	if strings.HasSuffix(dst, "gz") {
+		compression = archiver.Gz{}
+	} else if strings.HasSuffix(dst, "xz") {
+		compression = archiver.Xz{}
+	} else {
+		compression = archiver.Gz{}
+	}
+	format := archiver.CompressedArchive{
+		Compression: compression,
+		Archival:    archiver.Tar{},
+	}
+	err = format.Archive(context.Background(), out, files)
+	if err != nil {
+		return err
+	}
+	return nil
+}

+ 19 - 0
server/utils/file_test.go

@@ -0,0 +1,19 @@
+package utils
+
+import (
+	"github.com/astaxie/beego/logs"
+	"testing"
+)
+
+func TestZipDir(t *testing.T) {
+	src := "../data/files/fcKFrcJamnjUTCI"
+	dst := "../data/files/fcKFrcJamnjUTCI.tar.xz"
+	err := TarXz(dst, src)
+
+	if err != nil {
+		logs.Error("zip fail", err)
+	} else {
+		logs.Info("zip ok")
+	}
+
+}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 6 - 0
server/views/index.tpl


+ 41 - 0
src/App.css

@@ -0,0 +1,41 @@
+#nginx_ui_root {
+    height: 100%;
+    width: 100%;
+    overflow: hidden;
+}
+
+.logo {
+  height: 6em;
+  padding: 1.5em;
+  will-change: filter;
+  transition: filter 300ms;
+}
+.logo:hover {
+  filter: drop-shadow(0 0 2em #646cffaa);
+}
+.logo.react:hover {
+  filter: drop-shadow(0 0 2em #61dafbaa);
+}
+
+@keyframes logo-spin {
+  from {
+    transform: rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+  a:nth-of-type(2) .logo {
+    animation: logo-spin infinite 20s linear;
+  }
+}
+
+.card {
+  padding: 2em;
+}
+
+.read-the-docs {
+  color: #888;
+}

+ 28 - 0
src/App.tsx

@@ -0,0 +1,28 @@
+import './App.css'
+import {MyRouter} from "./routes";
+import { Provider } from 'react-redux';
+import { persistor, store } from './store';
+import {PersistGate} from "redux-persist/integration/react";
+import {ConfigProvider} from "antd";
+import zhCN from 'antd/lib/locale/zh_CN';
+import 'antd/dist/antd.css'
+import 'planning-tools/dist/umd/planning-tools.min.css'
+
+function App() {
+
+  return (
+    <>
+      <Provider store={store}>
+        <PersistGate loading persistor={persistor}>
+          <ConfigProvider locale={zhCN}>
+            <MyRouter />
+          </ConfigProvider>
+        </PersistGate>
+
+      </Provider>
+
+    </>
+  )
+}
+
+export default App

+ 27 - 0
src/adapter/index.js

@@ -0,0 +1,27 @@
+/**
+ * 神奇了,这个东西居然没有了,可能跟打包工具有关系
+ */
+if (!window.caches) {
+    window.caches = {
+        delete(cacheName) {
+            return false
+        },
+        has(cacheName) {
+            return false
+        },
+        keys() {
+            return []
+        },
+        /**
+         *
+         * @param request RequestInfo | URL
+         * @param options MultiCacheQueryOptions
+         */
+        match(request, options) {
+
+        },
+        open(cacheName) {
+
+        }
+    }
+}

+ 120 - 0
src/api/nginx.ts

@@ -0,0 +1,120 @@
+import request from "./request.ts";
+import {BaseResp, INginxCerts, IServerHost} from "../models/api.ts";
+import {INginx, INginxServer} from "../models/nginx.ts";
+import {createServer, createServerHost} from "../pages/nginx/utils/nginx.ts";
+
+
+type RefreshHttpData = {
+  id: number
+  httpConf: string
+  httpData: string
+}
+
+
+export const NginxApis= {
+
+  findAll: () => request.get<BaseResp<INginx[]>>('/nginx'),
+  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+  // @ts-ignore
+  updateOrAdd: (data: Partial<INginx>) => request.post<BaseResp<INginx>>('/nginx', data, { disableErrorMsg: true, timeout: 60000 } as any),
+  /**
+   * 同步配置文件到本地,仅需要传递id  和httpConf, httpData 三个参数
+   * @param nginx
+   */
+  refreshHttp: (nginx: RefreshHttpData) => {
+    return request.post('/http/refresh', nginx, { timeout: 60000 })
+  },
+  getNginx: (id:number) => request.get<BaseResp<{nginx: INginx, servers: IServerHost[]}>>(`/nginx/${id}`),
+  delNginx: (id:number) => request.delete(`/nginx/${id}`),
+  /**
+   * 不更改配置文件,仅保存数据,方便某些特殊情况,一直手动修改配置文件
+   * @param nginx
+   * @param server
+   */
+  // add or update
+  updateServer: (nginx: INginx,server: Partial<INginxServer>) => {
+    const serverHost: Partial<IServerHost> = createServerHost(nginx,server);
+    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+    // @ts-ignore
+    return request.post<BaseResp<IServerHost>>('/server',serverHost, { disableErrorMsg: true } as any)
+      .then(({data})=>{
+        if (data.data){
+          return createServer(data.data)
+        }
+        return Promise.reject(data)
+      })
+  },
+  /**
+   * 更改配置文件,保存数据
+   * @param nginx
+   * @param server
+   */
+  refreshServer: (server: Partial<IServerHost>) => {
+    return request.post('/server/refresh', server, { timeout: 60000 })
+  },
+  deleteServer: (server: INginxServer) => request.delete(`/server`,{ data: { id: server.id}}),
+  status: (id:number) => request.post(`/nginx/${id}/status`, { }, { timeout: 60000 }),
+  startNginx: (id:number) => request.post(`/nginx/${id}/start`, { }, { timeout: 60000 }),
+  stopNginx: (id:number) => request.post(`/nginx/${id}/stop`, { }, { timeout: 60000 }),
+
+  /**
+   * 获取证书信息,不传name,则返回所有证书文件信息,传了name,则返回该证书的内容
+   * @param id
+   * @param name
+   */
+  getCerts: (id: number,name?: string) => request.get(`/nginx/${id}/certs`, { params: { name }}),
+  /**
+   * 保存证书信息
+   * @param id
+   * @param data
+   */
+  saveCerts: (id: number,data: INginxCerts) => request.post(`/nginx/${id}/certs`, data),
+  delCerts: (nginxId: number,id: number) => request.delete(`/nginx/${nginxId}/certs`, { params: { id } }),
+  /**
+   * 从配置的数据目录中同步
+   * @param id
+   * @param name
+   */
+  syncCerts: (id: number) => request.post(`/nginx/${id}/certs/sync`),
+
+}
+
+
+export type IDeployReq  ={
+  key: string
+  nginxId: number
+  /**
+   * 部署目录,资源部署目录,一般是root+name 或者是alias
+   */
+  dir: string
+  /**
+   * 是否清空文件夹再部署
+   */
+  clear?: boolean
+}
+/**
+ * 文件上传
+ */
+export const uploadApis = {
+  uploadFile: (entry: FileSystemFileEntry, id: string) => {
+    return new Promise<File>((resolve, reject) => {
+      entry.file(function (f){
+        resolve(f)
+      },function (err){
+        reject(err)
+      })
+    }).then(file=>{
+      const formData = new FormData()
+      formData.append("file", file)
+      formData.append("Path", entry.fullPath)
+      formData.append("Key", id)
+      return request.post('/file',formData, {
+        withCredentials: true,
+        headers: {
+          'Content-type' : 'multipart/form-data'
+        }
+      })
+    })
+  },
+  deploy:(data: IDeployReq)=>request.post('/file/deploy', data, {timeout: 120000}),
+}

+ 68 - 0
src/api/request.ts

@@ -0,0 +1,68 @@
+import axios, {AxiosResponse} from 'axios';
+import {BaseResp} from "../models/api.ts";
+import {Message, Notify} from "planning-tools";
+console.log('env', import.meta.env)
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore
+const CONFIG = window.CONFIG;
+if (!CONFIG.baseApi){
+  CONFIG.baseApi = '/api'
+}
+
+/**
+ * 支持网络请求
+ * @type {AxiosInstance}
+ */
+// create an axios instance
+const request = axios.create({
+  baseURL: CONFIG.baseApi,
+  withCredentials: true, // send cookies when cross-domain requests
+  timeout: 10000, // request timeout
+});
+
+request.interceptors.request.use(
+  (config) => {
+    if (!config.headers){
+      config.headers = {}
+    }
+    config.headers["Authorization"] = "token"
+    return config;
+  },
+  (error) => {
+    // do something with request error
+    console.log(error); // for debug
+    return Promise.reject(error);
+  },
+);
+
+request.interceptors.response.use((resp: AxiosResponse<BaseResp>)=>{
+  const disableErrorMsg = (resp.config as any)['disableErrorMsg']
+  if (resp.data && resp.data.code == 0){
+    return resp
+  }else if (resp.data) {
+    (!disableErrorMsg) && Notify.warn(resp.data.msg)
+    return Promise.reject(resp.data)
+  }else {
+    (!disableErrorMsg) && Notify.warn("请求错误")
+    return resp
+  }
+},error => {
+  let errData: any = {
+    code: 10
+  }
+  const disableErrorMsg = (error.request?.config as any)?.['disableErrorMsg']
+  if (error.response && error.response.data){
+    errData = error.response.data
+  }else if (error.message){
+    errData.msg = error.message;
+  }
+  if (!errData.code){
+    errData.msg = 'request fail'
+  }
+  (!disableErrorMsg)&& Message.error(errData.msg)
+  return Promise.reject(errData)
+})
+
+
+export default request

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
src/assets/react.svg


+ 29 - 0
src/components/BackButton.tsx

@@ -0,0 +1,29 @@
+/**
+ * @author tuonian
+ * @date 2023/6/30
+ */
+import {Button} from "antd";
+import {ArrowLeftOutlined} from "@ant-design/icons";
+import {useNavigate} from "react-router";
+
+
+type IProps = {
+  to?: string
+}
+export const BackButton = ({to}: IProps) => {
+
+  const navigate = useNavigate()
+
+  const goBack = ()=>{
+    if (to){
+      navigate(to, { replace: true })
+    }else {
+      navigate(-1)
+    }
+  }
+
+  return (<Button
+    ghost style={{color: '#1890ff',marginRight: 10}}
+                  onClick={goBack}
+                  icon={<ArrowLeftOutlined />} /> )
+}

+ 699 - 0
src/config/nginx_form.json

@@ -0,0 +1,699 @@
+{
+  "server": [
+    {
+      "key": "server_name",
+      "title": "域名",
+      "type": "string",
+      "ruleType": "reg",
+      "pattern": "^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,6}$",
+      "placeholder": "请填写域名",
+      "description": "eg. demo.domain.cn",
+      "width": 300
+    },
+    {
+      "key": "listen",
+      "type": "int",
+      "title": "监听",
+      "min": 0,
+      "max": 65535,
+      "width": 300
+    },
+    {
+      "key": "enable",
+      "title": "启用",
+      "type": "switch",
+      "description": "是否启用,如果不启用,将不会渲染该配置"
+    },
+    {
+      "key": "ssl",
+      "title": "https",
+      "type": "switch",
+      "cascade": {
+        "true": [
+          {
+            "key": "certName",
+            "type": "certs",
+            "placeholder": "选择SSL证书",
+            "title": "SSL证书",
+            "description": "选择SSL证书,如果没有,请填到“SSL证书”管理界面添加证书"
+          }
+        ]
+      }
+    },
+    {
+      "key": "http2",
+      "title": "http2",
+      "type": "switch",
+      "cascade": {
+        "true": [
+          {
+            "key": "http2_max_concurrent_streams",
+            "value": 1024,
+            "title": "最大并发流",
+            "type": "int",
+            "placeholder": "http2_max_concurrent_streams",
+            "description": "http2_max_concurrent_streams",
+            "width": 300
+          }
+        ]
+      }
+    },
+    {
+      "key": "proxy_settings",
+      "title": "更多代理设置",
+      "type": "proxy_settings",
+      "required": false,
+      "description": "更多代理设置"
+    },
+    {
+      "type": "locations",
+      "title": "代理/站点",
+      "key": "locations",
+      "required": false,
+      "description": "静态资源或者反向代理,路由规则"
+    },
+    {
+      "type": "gzip",
+      "title": "压缩配置",
+      "key": "gzip",
+      "required": false,
+      "description": "gzip"
+    },
+    {
+      "type": "auth",
+      "title": "鉴权",
+      "key": "auth_request",
+      "required": false,
+      "description": "ngx_http_auth_request_module:实现了基于一子请求的结果的客户端的授权。如果子请求返回2xx响应码,则允许访问。如果它返回401或403,则访问被拒绝并显示相应的错误代码。子请求返回的任何其他响应代码都被认为是错误的"
+    },
+    {
+      "key": "rewrite",
+      "type": "object",
+      "title": "rewrite",
+      "required": false,
+      "hideHeader": true,
+      "description": "格式:rewrite < regex > < replacement > [flag]",
+      "items": [
+        {
+          "key": "regex",
+          "title": "正则表达式",
+          "type": "string",
+          "width": 180,
+          "placeholder": "<regex> 正则匹配",
+          "required": false
+        },
+        {
+          "key": "replacement",
+          "title": "跳转路径",
+          "type": "string",
+          "placeholder": "<replacement> 跳转后的内容",
+          "width": 300,
+          "required": false
+        },
+        {
+          "key": "flag",
+          "title": "flag",
+          "type": "select",
+          "option": ["last","break","redirect","permanent"],
+          "width": 120,
+          "placeholder": "[flag] 标记",
+          "description": "last: 相当于Apache的【L】标记,表示完成rewrite;\nbreak:本条规则匹配完成即终止,不在匹配后面的任何规则;\nredirect: 返回302临时重定向,浏览器地址栏会显示跳转后的URL地址,爬虫不会更新url;\npermanent:返回301永久重定向,浏览器地址栏会显示跳转后的URL地址,爬虫更新url;"
+        }
+      ]
+    },
+    {
+      "key": "tmp_custom_config",
+      "title": "自定义配置",
+      "type": "textarea",
+      "hideHeader": true,
+      "description": "自定义配置,注意,每行结尾需要加“;”号,将会拼接在最后,不做任何修改,请注意格式",
+      "required": false,
+      "trim": false,
+      "width": 600
+    },
+    {
+      "type": "divider",
+      "key": "tmp_more_settings",
+      "collapsible": true,
+      "value": false,
+      "title": "更多设置",
+      "items": [
+        {
+          "key": "keepalive_timeout",
+          "type": "string",
+          "required": false,
+          "title": "keepalive_timeout",
+          "description": "eg. 10s",
+          "min": 0,
+          "ruleType": "reg",
+          "pattern": "(\\d)(s|m|h)$"
+        },
+        {
+          "key": "client_max_body_size",
+          "type": "string",
+          "placeholder": "请求体的最大大小",
+          "title": "最大请求体大小",
+          "description": "eg. 500m 20m",
+          "required": false,
+          "value": "10m"
+        },
+        {
+          "key": "access_log",
+          "type": "string",
+          "placeholder": "access日志文件路径",
+          "required": false,
+          "title": "Access日志"
+        },
+        {
+          "key": "charset",
+          "title": "编码",
+          "required": false,
+          "description": "charset"
+        },
+        {
+          "key": "ssl_session_timeout",
+          "type": "string",
+          "placeholder": "ssl_session_timeout",
+          "title": "ssl_session_timeout",
+          "description": "eg. 5m 60s",
+          "required": false
+        },
+        {
+          "key": "ssl_prefer_server_ciphers",
+          "type": "select",
+          "placeholder": "ssl_prefer_server_ciphers",
+          "title": "ssl_prefer_server_ciphers",
+          "option": ["on","off"],
+          "value": "on",
+          "required": false,
+          "width": 260
+        },
+        {
+          "key": "ssl_ciphers",
+          "type": "string",
+          "placeholder": "ssl_ciphers",
+          "title": "ssl_ciphers",
+          "description": "ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4",
+          "required": false,
+          "width": 450
+        },
+        {
+          "key": "ssl_protocols",
+          "type": "select",
+          "mode": "multiple",
+          "placeholder": "ssl_protocols",
+          "title": "SSL协议",
+          "option": ["TLSv1","TLSv1.1","TLSv1.2","TLSv2","TLSv3"],
+          "required": false,
+          "width": 450
+        }
+      ]
+    }
+  ],
+  "addNginx": [
+    {
+      "type": "string",
+      "key": "name",
+      "title": "名称"
+    },
+    {
+      "key": "isLocal",
+      "type": "switch",
+      "description": "本地实例,直接在服务器上运行名称,非本地实例,需要配置SSH连接信息,使用SSH执行相关命令",
+      "value": true,
+      "title": "本地实例",
+      "cascade": {
+        "false": [
+          {
+            "type": "string",
+            "key": "ipAddr",
+            "title": "IP地址"
+          },
+          {
+            "type": "int",
+            "key": "port",
+            "title": "端口"
+          },
+          {
+            "type": "string",
+            "key": "user",
+            "title": "用户名"
+          },
+          {
+            "type": "password",
+            "key": "password",
+            "title": "密码"
+          }
+        ]
+      }
+    }
+  ],
+  "nginxSettings": [
+    {
+      "key": "isServer",
+      "title": "服务方式运行",
+      "type": "switch",
+      "description": "以服务方式运行,则使用service nginx start|stop|reload 等命令,否则使用nginx -s reload|stop 等命令"
+    },
+    {
+      "key": "nginxPath",
+      "title": "nginx位置",
+      "type": "string",
+      "description": "nginx的文件所在的绝对路径,默认为:/usr/sbin/nginx,可使用nginx -V 查看参数--sbin-path;或者先填写,保存成功之后返回版本信息,可参考修正即可",
+      "value": "/usr/sbin/nginx"
+    },
+    {
+      "key": "nginxDir",
+      "title": "nginx配置目录",
+      "type": "string",
+      "description": "nginx的配置文件所在的目录,即nginx.conf所在的目录,一般为:/etc/nginx,可使用nginx -V 查看参数 --prefix",
+      "value": "/etc/nginx"
+    },
+    {
+      "key":"dataDir",
+      "type": "string",
+      "title": "数据目录",
+      "description": "nginx的自定义配置文件所在目录"
+    },
+    {
+      "key": "remark",
+      "title": "备注信息",
+      "placeholder": "输入备注",
+      "type": "textarea",
+      "required": false,
+      "trim": false
+    }
+  ],
+  "nginxConf": [
+    {
+      "key": "user",
+      "value": "nginx",
+      "title": "user",
+      "placeholder": "nginx user"
+    },
+    {
+      "key": "worker_processes",
+      "title": "工作进程数量",
+      "type": "string",
+      "ruleType": "reg",
+      "pattern": "^(auto|\\d+)$",
+      "description": "auto或者指定数量"
+    },
+    {
+      "key": "error_log",
+      "title": "错误日志路径",
+      "type": "string",
+      "value": "/var/log/nginx/error.log notice",
+      "description": "eg. /var/log/nginx/error.log notice"
+    },
+    {
+      "key": "pid",
+      "title": "pid位置",
+      "type": "string",
+      "value": "/var/run/nginx.pid",
+      "description": "eg. /var/run/nginx.pid"
+    },
+    {
+      "key": "temp.events",
+      "type": "divider",
+      "title": "events配置",
+      "description": "nginx events 模块主要是nginx 和用户交互网络连接优化的配置内容",
+      "items": [
+        {
+          "key": "events.accept_mutex",
+          "title": "accept_mutex",
+          "type": "switch",
+          "value": true,
+          "required": false,
+          "description": "这个配置主要可以用来解决常说的\"惊群\"问题。大致意思是在某一个时刻,客户端发来一个请求连接,Nginx后台是以多进程的工作模式,也就是说有多个worker进程会被同时唤醒,但是最终只会有一个进程可以获取到连接,如果每次唤醒的进程数目太多,就会影响Nginx的整体性能。如果将上述值设置为on(开启状态),将会对多个Nginx进程接收连接进行序列号,一个个来唤醒接收,就防止了多个进程对连接的争抢"
+        },
+        {
+          "key": "events.worker_connections",
+          "type": "int",
+          "layout": "form",
+          "title": "最大连接数",
+          "description": "用来配置单个worker进程最大的连接数,nginx 默认连接数是1024",
+          "min": 0,
+          "max": 65536,
+          "value": 1024,
+          "required": false
+        },
+        {
+          "key": "events.multi_accept",
+          "title": "multi_accept",
+          "description": "用来设置是否允许同时接收多个网络连接",
+          "type": "switch",
+          "value": false,
+          "required": false
+        },
+        {
+          "key": "events.use",
+          "title": "网络驱动",
+          "description": "用来设置Nginx服务器选择哪种事件驱动来处理网络消息;另外这些值的选择,我们也可以在编译的时候使用:–with-select_module、–without-select_module、 --with-poll_module、–without-poll_module来设置是否需要将对应的事件驱动模块编译到Nginx的内核",
+          "type": "select",
+          "option": ["select","poll","epoll","kqueue"],
+          "required": false
+        }
+      ]
+    },
+    {
+      "key": "temp.http",
+      "title": "http配置",
+      "type": "divider",
+      "items": [
+        {
+          "key": "http.include",
+          "type": "string",
+          "value": "/etc/nginx/mime.types",
+          "title": "include"
+        },
+        {
+          "key": "http.default_type",
+          "type": "string",
+          "value": "application/octet-stream",
+          "title": "default_type"
+        },
+        {
+          "key": "http.log_format",
+          "title": "日志格式",
+          "type": "array",
+          "items": [
+            {
+              "type": "textarea",
+              "key": "name",
+              "value": "main",
+              "title": "格式名称",
+              "rows": 4,
+              "placeholder": "日志格式名称,eg. main compression",
+              "width": 200,
+              "description": "日志格式名称,eg. main compression log1 log2",
+              "trim": false
+            },
+            {
+              "type": "textarea",
+              "key": "content",
+              "value": "",
+              "title": "日志格式",
+              "rows": 4,
+              "width": 400,
+              "trim": false,
+              "placeholder": "'$remote_addr - $remote_user [$time_local] \"$request\" '\n                      '$status $body_bytes_sent \"$http_referer\" '\n                      '\"$http_user_agent\" \"$http_x_forwarded_for\"'",
+              "description": "参数                      说明                                         示例\n$remote_addr             客户端地址                                    211.28.65.253\n$remote_user             客户端用户名称                                --\n$time_local              访问时间和时区                                18/Jul/2012:17:00:01 +0800\n$request                 请求的URI和HTTP协议                           \"GET /article-10000.html HTTP/1.1\"\n$http_host               请求地址,即浏览器中你输入的地址(IP或域名)     www.wang.com 192.168.100.100\n$status                  HTTP请求状态                                  200\n$upstream_status         upstream状态                                  200\n$body_bytes_sent         发送给客户端文件内容大小                        1547\n$http_referer            url跳转来源                                   https://www.baidu.com/\n$http_user_agent         用户终端浏览器等信息                           \"Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0; SV1; GTB7.0; .NET4.0C;\n$ssl_protocol            SSL协议版本                                   TLSv1\n$ssl_cipher              交换数据中的算法                               RC4-SHA\n$upstream_addr           后台upstream的地址,即真正提供服务的主机地址     10.10.10.100:80\n$request_time            整个请求的总时间                               0.205\n$upstream_response_time  请求过程中,upstream响应时间                    0.002\n"
+            }
+          ]
+        },
+        {
+          "key": "http.access_log",
+          "title": "访问日志",
+          "type": "object",
+          "value": "/var/log/nginx/access.log  main",
+          "items": [
+            {
+              "key": "name",
+              "type": "string",
+              "title": "格式标签",
+              "placeholder": "日志格式配置的格式名称",
+              "description": "输入日志格式配置的格式名称",
+              "width": 200
+            },
+            {
+              "key": "path",
+              "type": "string",
+              "title": "日志路径",
+              "width": 250
+            }
+          ]
+        },
+        {
+          "key": "http.sendfile",
+          "type": "select",
+          "required": false,
+          "option": ["on","off"],
+          "title": "sendfile"
+        },
+        {
+          "key": "http.tcp_nopush",
+          "type": "select",
+          "required": false,
+          "option": ["on","off"],
+          "title": "tcp_nopush"
+        },
+        {
+          "type": "gzip",
+          "title": "压缩配置",
+          "key": "http.gzip",
+          "required": false,
+          "description": "gzip"
+        },
+        {
+          "key": "http.keepalive_timeout",
+          "type": "int",
+          "required": false,
+          "title": "keepalive_timeout",
+          "description": "单位为秒(s), 0表示不限制",
+          "min": 0
+        },
+        {
+          "key": "http.proxy_settings",
+          "title": "代理设置",
+          "type": "proxy_settings",
+          "required": false
+        },
+        {
+          "key": "http.more",
+          "type": "textarea",
+          "required": false,
+          "title": "更多配置",
+          "description": "自定义配置,每行需要有分隔符号",
+          "trim": false
+        }
+      ]
+    },
+    {
+      "key": "stream",
+      "type": "divider",
+      "collapsible": true,
+      "title": "TCP/UDP配置",
+      "description": "stream配置,需要注意安装的nginx版本是否支持;默认情况下,没有构建此模块。 -必须使用-with stream配置参数启用",
+      "items": [
+        {
+          "key": "stream.log_format",
+          "title": "日志格式",
+          "type": "array",
+          "items": [
+            {
+              "type": "textarea",
+              "key": "name",
+              "value": "tcp_format",
+              "title": "格式名称",
+              "rows": 4,
+              "placeholder": "日志格式名称,eg. tcp_format",
+              "width": 200,
+              "description": "日志格式名称,eg. tcp_format",
+              "trim": false
+            },
+            {
+              "type": "textarea",
+              "key": "content",
+              "value": "",
+              "title": "日志格式",
+              "rows": 4,
+              "width": 400,
+              "trim": false,
+              "placeholder": "'$time_local|$remote_addr|$protocol|$status|$bytes_sent|$bytes_received|$session_time|$upstream_addr|$upstream_bytes_sent|$upstream_bytes_received|$upstream_connect_time'"
+            }
+          ]
+        },
+        {
+          "key": "stream.access_log",
+          "title": "访问日志",
+          "type": "object",
+          "value": {
+            "name": "tcp_format",
+            "path": "/var/log/nginx/access_stream.log"
+          },
+          "items": [
+            {
+              "key": "name",
+              "type": "string",
+              "title": "格式标签",
+              "placeholder": "日志格式配置的格式名称",
+              "description": "输入日志格式配置的格式名称",
+              "width": 200,
+              "value": "tcp_format"
+            },
+            {
+              "key": "path",
+              "type": "string",
+              "title": "日志路径",
+              "width": 250
+            }
+          ]
+        },
+        {
+          "key": "stream.error_log",
+          "title": "错误日志",
+          "type": "string",
+          "value": "/var/log/nginx/error_stream.log"
+        }
+      ]
+    }
+  ],
+  "upstream": [
+    {
+      "title": "名称",
+      "key": "name",
+      "width": 100,
+      "description": "名称相同则为同一组负载均衡,只支持英文字母",
+      "type": "string"
+    },
+    {
+      "title": "负载方式",
+      "key": "type",
+      "type": "select",
+      "option": ["ip_hash","weight"],
+      "width": 100,
+      "required": false,
+      "description": "ip_hash: 每个请求按访问ip的hash结果分配,这样每个访客固定访问一个后端服务器,可以解决session不能跨服务器的问题。如果后端服务器down掉,要手工down掉;weight:指定轮询几率,weight和访问比率成正比,如果后端服务器down掉,能自动剔除。"
+    },
+    {
+      "title": "是否启用",
+      "key": "enable",
+      "type": "switch",
+      "value": true
+    },
+    {
+      "title": "服务配置",
+      "type": "array",
+      "key": "servers",
+      "items": [
+        {
+          "title": "主机",
+          "type": "string",
+          "key": "host",
+          "description": "后端服务IP",
+          "width": 150
+        },
+        {
+          "title": "端口",
+          "type": "int",
+          "key": "port",
+          "description": "后端服务端口",
+          "width": 100
+        },
+        {
+          "title": "权重",
+          "type": "int",
+          "key": "weight",
+          "description": "权重,ip_hash模式下不生效,数字越大,越高,为0将剔除",
+          "width": 80,
+          "min": 0,
+          "value": 100
+        },
+        {
+          "title": "状态/角色",
+          "type": "select",
+          "key": "status",
+          "option": ["normal","down","backup"],
+          "description": "weight,backup 不能和 ip_hash 关键字一起使用;down:表示当前的server暂时不参与负载",
+          "width": 100,
+          "required": false
+        },
+        {
+          "title": "max_fails",
+          "type": "int",
+          "key": "max_fails",
+          "required": false,
+          "description": "最大失败次数,也就是最多进行 3 次尝试,默认为1",
+          "width": 100
+        },
+        {
+          "title": "超时时间",
+          "type": "int",
+          "key": "fail_timeout",
+          "required": false,
+          "description": "超时时间,单位秒,默认值是10s",
+          "width": 100
+        }
+      ]
+    }
+  ],
+  "stream": [
+    {
+      "key": "listen",
+      "type": "int",
+      "value": 3306,
+      "title": "端口",
+      "width": 100
+    },
+    {
+      "key": "proxy_pass",
+      "type": "stream_proxy_pass",
+      "title": "后端服务",
+      "description": "IP:PORT 或者upstream的名称",
+      "width": 200
+    },
+    {
+      "key": "enable",
+      "type": "switch",
+      "title": "启用",
+      "description": "如果不启用,不会渲染该组配置信息",
+      "required": false
+    },
+    {
+      "key": "proxy_connect_timeout",
+      "type": "int",
+      "value": 10,
+      "title": "connect_timeout",
+      "placeholder": "与被代理服务器建立连接的超时时间,单位为s",
+      "required": false
+    },
+    {
+      "key": "proxy_timeout",
+      "type": "int",
+      "value": 10,
+      "title": "超时时间",
+      "placeholder": "获取被代理服务器的响应最大超时时间,单位为s",
+      "required": false
+    },
+    {
+      "key": "proxy_next_upstream",
+      "type": "switch",
+      "value": true,
+      "title": "next_upstream",
+      "description": "当被代理的服务器返回错误或超时时,将未返回响应的客户端连接请求传递给upstream中的下一个服务器",
+      "required": false
+    },
+    {
+      "key": "proxy_next_upstream_tries",
+      "type": "int",
+      "value": 3,
+      "title": "最大错误次数",
+      "description": "转发尝试请求最多3次",
+      "required": false
+    },
+    {
+      "key": "proxy_next_upstream_timeout",
+      "type": "int",
+      "value": 10,
+      "title": "总尝试超时时间",
+      "description": "总尝试超时时间,单位为s",
+      "required": false
+    },
+    {
+      "key": "proxy_socket_keepalive",
+      "type": "switch",
+      "value": true,
+      "title": "心跳",
+      "description": "开启SO_KEEPALIVE选项进行心跳检测",
+      "required": false
+    },
+    {
+      "key": "remark",
+      "type": "string",
+      "placeholder": "备注信息",
+      "required": false,
+      "title": "备注"
+    }
+  ]
+}

+ 68 - 0
src/config/nginx_template.json

@@ -0,0 +1,68 @@
+{
+  "nginxConf": {
+    "user": "nginx",
+    "worker_processes": "auto",
+    "error_log": "/var/log/nginx/error.log notice",
+    "pid": "/var/run/nginx.pid",
+    "events.worker_connections": 1024,
+    "http.include": "/etc/nginx/mime.types",
+    "http.default_type": "application/octet-stream",
+    "http.log_format": [
+      {
+        "key": "http.log_formatXK1BCq0XKQCMEuV",
+        "name": "main",
+        "content": "'$remote_addr - $remote_user [$time_local] \"$request\" '\n                      '$status $body_bytes_sent \"$http_referer\" '\n                      '\"$http_user_agent\" \"$http_x_forwarded_for\"'"
+      }
+    ],
+    "http.access_log": {
+      "key": "http.access_logqwY8npz3ypNjgIA",
+      "name": "main",
+      "path": "/var/log/nginx/access.log"
+    },
+    "http.sendfile": "off",
+    "http.tcp_nopush": "off",
+    "http.keepalive_timeout": 0,
+    "http.gzip": "off",
+    "stream": true,
+    "stream.log_format": [
+      {
+        "key": "stream.log_format8avSYRKrTjzgzcF",
+        "name": "tcp_format",
+        "content": "'$time_local|$remote_addr|$protocol|$status|$bytes_sent|$bytes_received|$session_time|$upstream_addr|$upstream_bytes_sent|$upstream_bytes_received|$upstream_connect_time'"
+      }
+    ],
+    "stream.access_log": {
+      "key": "stream.access_logpuBlWTJXD64QiOL",
+      "name": "tcp_format",
+      "path": "/var/log/nginx/access_stream.log"
+    },
+    "stream.error_log": "/var/log/nginx/error_stream.log"
+  },
+  "server": {
+    "port": 80,
+    "enable": true,
+    "ssl_session_timeout": "5m",
+    "ssl_ciphers": "ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4",
+    "ssl_protocols": ["TLSv1","TLSv1.1","TLSv1.2"],
+    "ssl_prefer_server_ciphers": "on",
+    "locations": [
+      {
+        "id": "default",
+        "name": "默认",
+        "match": {
+          "path": "/"
+        },
+        "root": "/data/www",
+        "proxy_type": "static",
+        "enable": true
+      }
+    ]
+  },
+  "location": {
+
+  },
+  "addNginx": {
+
+  },
+  "nginxSettings": {}
+}

+ 75 - 0
src/index.css

@@ -0,0 +1,75 @@
+:root {
+  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+  line-height: 1.5;
+  font-weight: 400;
+
+  color-scheme: light dark;
+  color: rgba(255, 255, 255, 0.87);
+  background-color: #242424;
+
+  font-synthesis: none;
+  text-rendering: optimizeLegibility;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  -webkit-text-size-adjust: 100%;
+}
+
+html,body{
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+}
+
+a {
+  font-weight: 500;
+  color: #646cff;
+  text-decoration: inherit;
+}
+a:hover {
+  color: #535bf2;
+}
+
+body {
+  margin: 0;
+  display: flex;
+  place-items: center;
+  min-width: 320px;
+  min-height: 100vh;
+}
+
+h1 {
+  font-size: 3.2em;
+  line-height: 1.1;
+}
+
+button {
+  border-radius: 8px;
+  border: 1px solid transparent;
+  padding: 0.6em 1.2em;
+  font-size: 1em;
+  font-weight: 500;
+  font-family: inherit;
+  background-color: #1a1a1a;
+  cursor: pointer;
+  transition: border-color 0.25s;
+}
+button:hover {
+  border-color: #646cff;
+}
+button:focus,
+button:focus-visible {
+  outline: 4px auto -webkit-focus-ring-color;
+}
+
+@media (prefers-color-scheme: light) {
+  :root {
+    color: #213547;
+    background-color: #ffffff;
+  }
+  a:hover {
+    color: #747bff;
+  }
+  button {
+    background-color: #f9f9f9;
+  }
+}

+ 56 - 0
src/main.tsx

@@ -0,0 +1,56 @@
+import React from 'react'
+import ReactDOM, {Root} from 'react-dom/client'
+import './adapter/index.js'
+import App from './App.tsx'
+import './index.css'
+import './styles/index.less'
+import renderWithQiankun from "vite-plugin-qiankun/es/helper";
+
+let root: Root | null
+
+const render = (props: any ={}) => {
+  console.log('[nginx-ui] render', props);
+  const {container} = props;
+  const rootContainer = container ? (container as HTMLElement).querySelector('#nginx_ui_root') : document.getElementById('nginx_ui_root')
+  root = ReactDOM.createRoot(rootContainer as never);
+  root.render(
+    <React.StrictMode>
+      <App />
+    </React.StrictMode>
+  )
+}
+
+const initQianKun = ()=>{
+  renderWithQiankun({
+    bootstrap(){
+      console.log('bootstrap')
+    },
+    mount(props){
+      console.log('[nginx-ui] mount', props)
+      render(props)
+    },
+    unmount(){
+      console.log('unmount')
+      if (!root){
+        return
+      }
+      try {
+        root.unmount()
+        root = null
+      }catch (e) {
+        console.log('[nginx-ui] unmount fail', e)
+      }
+    },
+    update(props){
+      console.log('update', props)
+    }
+  })
+}
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore
+if (window.__POWERED_BY_QIANKUN__){
+  initQianKun()
+} else {
+  render()
+}

+ 41 - 0
src/models/api.ts

@@ -0,0 +1,41 @@
+/**
+ * 后端返回数据的基本格式
+ */
+export type BaseResp<T =any> = {
+  code: number
+  msg: string
+  data?: T
+}
+
+/**
+ * 虚拟主机,后端,跟前端不一致
+ */
+export type IServerHost = {
+  id: number
+  name: string
+  nginxId: number
+  enable?: boolean
+  serverData: string
+  serverConf: string
+  remark: string
+  /**
+   * 是否为TCP/UDP代理
+   */
+  isStream?: boolean
+}
+
+/**
+ * 证书信息
+ */
+export type INginxCerts = {
+  id: number
+  nginxId: number
+  serviceName: string
+  subjectName?: string
+  hintMsg?: string
+  pem: string
+  key: string
+  createdAt?: string
+  expiresAt?: string
+  remark?: string
+}

+ 279 - 0
src/models/nginx.ts

@@ -0,0 +1,279 @@
+import {FormColumnType} from "planning-tools";
+import {NgxModuleData} from "../pages/nginx/components/input.ts";
+
+export type INginx = {
+  id: number
+  name: string
+  uid: string
+  /**
+   * 数据目录,所有自定义配置文件都在里面
+   * conf.d stream.d backup certs
+   */
+  dataDir: string
+  /**
+   * nginx的配置文件主目录,及nginx.conf 配置文件所在的目录
+   */
+  nginxDir: string
+  /**
+   * nginx可执行文件
+   */
+  nginxPath?:string
+  isLocal: boolean
+  ipAddr: string
+  port: number
+  user: string
+  password: string
+  httpData: string
+  httpConf: string
+  remark: string
+  /**
+   * 版本信息
+   */
+  versionInfo?: string
+}
+
+/**
+ * 虚拟主机或者 TCP/UDP代理
+ */
+export type INginxServer = {
+  /**
+   * 唯一标识
+   */
+  id: number
+  /**
+   * 是否是负载均衡
+   */
+  isUpstream?: boolean
+  /**
+   * 是否为TCP/UDP代理
+   */
+  isStream?: boolean
+  nginxId: number
+  enable?: boolean
+  http2?: boolean
+  /**
+   * 配置文件,当前的配置文件
+   */
+  confData?: string
+
+  server_name: string
+  listen: number
+  ssl?: boolean
+  charset?: string
+  access_log?: string
+  error_log?: string
+  /**
+   * 客户端最大的请求体大小,500m 1g
+   */
+  client_max_body_size?: string
+  /**
+   * 证书名称,平台托管的证书名称
+   */
+  certName?: string
+  ssl_certificate?: string
+  ssl_certificate_key?: string
+  /**
+   * eg. 5m 1h
+   */
+  ssl_session_timeout?: string
+  // ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4
+  ssl_ciphers?: string
+  // TLSv1 TLSv1.1 TLSv1.2
+  ssl_protocols?: string[]
+  ssl_prefer_server_ciphers?: 'on'|'off'
+
+  locations?: INginxLocation[]
+  upstreams?: IUpstream[]
+  streams?: INginxStream[]
+  rewrite?: IRewrite
+  remark?: string
+
+  proxy_settings?: NgxModuleData
+  tmp_custom_config?: string
+  gzip?: NgxModuleData
+}
+/**
+ * 负载均衡,跟虚拟主机放在一起吧,方便
+ */
+export type IUpstream = {
+  name: string
+  /**
+   * weight\backup 不能和 ip_hash 关键字一起使用。
+   * ip_hash 或者weight 轮训
+   */
+  type?:'ip_hash' | 'weight'
+  /**
+   * 是否启用
+   */
+  enable?: boolean
+  servers: {
+    host: string
+    port: number
+    weight?: number
+    /**
+     * down:表示当前的server暂时不参与负载
+     * weight:默认为1.weight越大,负载的权重就越大。
+     * backup: 其它所有的非backup机器down或者忙的时候,请求backup机器。所以这台机器压力会最轻。
+     */
+    status?: 'down' | 'backup' | 'normal'
+    /**
+     * 最大失败次数,也就是最多进行 3 次尝试,默认为1
+     */
+    max_fails?: number
+    /**
+     * 超时时间,单位秒,默认值是10s
+     */
+    fail_timeout?: number
+  }[]
+}
+
+export type INginxStream = {
+  /**
+   * 唯一索引
+   */
+  key: string
+  listen: number
+  /**
+   * 与被代理服务器建立连接的超时时间为5s
+   */
+  proxy_connect_timeout?: number
+  /**
+   * 获取被代理服务器的响应最大超时时间为10s
+   */
+  proxy_timeout?: number
+  /**
+   * 当被代理的服务器返回错误或超时时,将未返回响应的客户端连接请求传递给upstream中的下一个服务器
+   */
+  proxy_next_upstream?: boolean
+  /**
+   * 总尝试超时时间为10s
+   */
+  proxy_next_upstream_tries?: number
+  /**
+   *  总尝试超时时间为10s
+   */
+  proxy_next_upstream_timeout?: number
+  /**
+   * 开启SO_KEEPALIVE选项进行心跳检测
+   */
+  proxy_socket_keepalive?: boolean
+  /**
+   * proxy_pass
+   */
+  proxy_pass: string
+  /**
+   * 是否启用
+   */
+  enable?: boolean
+}
+
+export type PNginxServer = Partial<INginxServer>
+
+/**
+ * 键值对
+ */
+export type KeyValue = {
+  name: string
+  value: string
+}
+
+/**
+ * nginx 的location配置
+ */
+export type INginxLocation = Omit<NgxModuleData, "data"> & {
+  /**
+   * 唯一标识
+   */
+  id: string
+  /**
+   * location的名称
+   */
+  name: string;
+  /**
+   * 匹配规则
+   */
+  match: {
+    path: string
+    regex?: string
+  }
+  index?: string
+  root?: string
+  alias?: string
+  proxy_set_header?: IProxyHeader[]
+  add_header?: IProxyHeader[]
+  proxy_pass?: string
+  // http_502 http_504 http_404 error timeout invalid_header
+  proxy_next_upstream?: string[]
+  //eg. 60s 1m
+  proxy_connect_timeout?: string
+  // 1.1
+  proxy_http_version?: string
+  rewrite?: IRewrite
+
+  proxy_settings?: NgxModuleData
+  gzip?: NgxModuleData
+  tmp_custom_config?: string
+  proxy_type?: 'proxy'| 'static' | 'returnBody' | 'other'
+  /**
+   * 是否为内部路由
+   */
+  internal?: boolean
+  return?: {
+    code: number
+    content: string
+  }
+  /**
+   * 临时数据,表示
+   */
+  __index__?: number
+}
+
+export type PLocation = Partial<INginxLocation>
+
+export type IProxyHeader = {
+  name: string
+  value: string
+}
+
+
+export type IRewrite = {
+  /**
+   * 正则表达式
+   */
+  regex: string
+  /**
+   * 跳转后的内容
+   */
+  replacement: string
+  /**
+   * rewrite支持的flag标记
+   */
+  flag: 'last' | 'break' | 'redirect' | 'permanent'
+}
+
+/**
+ * nginx的自动化表单配置
+ */
+export type INginxFormConfig = {
+  server: FormColumnType[]
+  location: FormColumnType[]
+  addNginx: FormColumnType[]
+  nginxSettings: FormColumnType[]
+  nginxConf: FormColumnType[]
+  /**
+   * 负载均衡的
+   */
+  upstream: FormColumnType[]
+  stream: FormColumnType[]
+}
+
+/**
+ * 给定的初始值模板
+ */
+export type INginxFormTemplate = {
+  server: Partial<INginxServer>,
+  location: any,
+  addNginx: any,
+  nginxSettings: any,
+  nginxConf: any
+}

+ 52 - 0
src/pages/nginx/certs/index.less

@@ -0,0 +1,52 @@
+.cert-page{
+  padding: 0 10px;
+  .cert-list{
+    display: flex;
+    flex-direction: column;
+  }
+  .cert-tags{
+    padding: 10px;
+    display: flex;
+    flex-direction: row;
+    flex-wrap: wrap;
+    .ant-tag{
+      margin-bottom: 5px;
+      cursor: pointer;
+    }
+  }
+
+}
+.cert-edit-drawer{
+  .ant-drawer-header{
+    padding: 5px 10px;
+  }
+  .ant-drawer-body{
+    padding: 10px;
+
+    .ant-picker{
+      min-width: 60%;
+    }
+  }
+
+  .cert-data{
+    max-width: 900px;
+    h5{
+      font-weight: bold;
+      font-size: 16px;
+    }
+  }
+  .inline-item{
+    display: flex;
+    flex-direction: row;
+    .ant-form-item{
+      flex: 1;
+      margin-right: 10px;
+    }
+  }
+  .footer-item{
+    padding-left: 16%;
+    .ant-btn+.ant-btn{
+      margin-left: 10px;
+    }
+  }
+}

+ 249 - 0
src/pages/nginx/certs/index.tsx

@@ -0,0 +1,249 @@
+import {NginxApis} from "../../../api/nginx.ts";
+import {useAppSelector} from "../../../store";
+import {useEffect, useState} from "react";
+import {Button, Drawer, Form, Input, Modal, Table, Tooltip, Upload} from "antd";
+import {
+  DeleteOutlined,
+  EditOutlined,
+  ImportOutlined,
+  PlusOutlined, QuestionOutlined,
+  SyncOutlined,
+  UploadOutlined
+} from "@ant-design/icons";
+
+import './index.less'
+import {INginxCerts} from "../../../models/api.ts";
+import {RcFile} from "antd/es/upload";
+import {isNull, Message} from "planning-tools";
+import {ModalStaticFunctions} from "antd/es/modal/confirm";
+
+/**
+ * 证书管理
+ * @constructor
+ */
+export const NginxCerts = () => {
+    const nginx = useAppSelector(state => state.nginx.current)
+
+    const [loading, setLoading] = useState(false)
+    const [certs, setCerts] = useState<INginxCerts[]>([])
+    const [cert, setCert] = useState<Partial<INginxCerts>>()
+    const [modal,contextHolder] = Modal.useModal()
+
+
+  const [form] = Form.useForm()
+
+    const onBeforeUpload = (name: 'pem' | 'key', file: RcFile) => {
+        console.log('onBeforeUpload', name, file.name)
+        file.text().then(v=>{
+            const data = {...cert, [name]: v };
+            setCert(data as INginxCerts)
+            form.setFieldsValue(data)
+        })
+
+        return false
+    }
+
+    const fetchList = () => {
+        if (!nginx?.id) {
+            return
+        }
+        setLoading(true)
+        NginxApis.getCerts(nginx.id)
+            .then(({data}) => {
+                const content = data.data;
+                if (!content) {
+                    setCerts([])
+                }else {
+                  setCerts(data.data)
+                }
+            })
+            .finally(() => {
+                setLoading(false)
+            })
+    }
+
+    const syncFromDisk = ()=>{
+      if (!nginx?.id){
+        return
+      }
+      setLoading(true)
+      NginxApis.syncCerts(nginx.id)
+        .then(() => {
+          fetchList()
+        })
+        .finally(() => {
+          setLoading(false)
+        })
+    }
+
+    const onAddData = ()=>{
+        setCert({})
+        form.resetFields()
+    }
+
+  const onEditCert = (data: INginxCerts)=>{
+    const fields = { ...data }
+    setCert(fields)
+    form.setFieldsValue(fields)
+  }
+
+    const onSubmitData = async ()=>{
+        if (!nginx?.id){
+          Message.warning('缓存数据异常,请退出到首页重新进去nginx实例配置页面。')
+            return
+        }
+        const values = await form.validateFields()
+        console.log('values',values);
+        setLoading(true);
+        const postData = { ...cert, ...values}
+        postData.nginxId = nginx.id
+        NginxApis.saveCerts(nginx.id, postData)
+            .then(({data})=>{
+                console.log('data',data);
+                Message.success('保存成功!');
+                setCert(undefined)
+                fetchList();
+            })
+            .finally(()=>{
+                setLoading(false)
+            })
+    }
+
+
+    useEffect(() => {
+        fetchList()
+    }, [])
+
+    return (<div className="page cert-page">
+        <div className="page-header">
+            <Button type="primary" loading={loading} onClick={fetchList} icon={<SyncOutlined/>}/>
+            <Button onClick={onAddData} icon={<PlusOutlined/>}/>
+            <div style={{flex:1}} />
+          <Tooltip placement="left" title="从数据目录中导入,适用于初始化;如果数据库中已存在,会覆盖,请谨慎处理">
+            <Button danger loading={loading} onClick={syncFromDisk} icon={<ImportOutlined/>}></Button>
+          </Tooltip>
+        </div>
+        <div className="page-container cert-list">
+          <Table rowKey="id" dataSource={certs} pagination={false}>
+            <Table.Column
+              title="名称"
+              dataIndex="serviceName"
+            />
+            <Table.Column
+              title="域名"
+              dataIndex="subjectName"
+              render={(v,data: INginxCerts)=>{
+                return (<>
+                  {v || '--'}
+                  { data.hintMsg ? <Tooltip title={data.hintMsg}><QuestionOutlined /></Tooltip> : null }
+                </>)
+              }}
+            />
+            <Table.Column title="添加时间" dataIndex="createdAt" />
+            <Table.Column title="过期时间" dataIndex="expiresAt" />
+            <Table.Column title="备注" dataIndex="remark" />
+            <Table.Column title="操作" dataIndex="ops"
+                          width={120}
+                          render={(_,data: INginxCerts)=>(<>
+                            <DelButton onRefresh={fetchList} cert={data} nginxId={nginx?.id || 0} modal={modal} />
+                            <Button onClick={()=>onEditCert(data)} type="link" icon={<EditOutlined />}></Button>
+            </>)} />
+          </Table>
+          <Drawer open={!!cert}
+                  width={750}
+                  className="cert-edit-drawer"
+                  onClose={()=>setCert(undefined)}
+                  destroyOnClose
+                  title={isNull(cert?.id) ?'添加证书': '编辑证书'}>
+
+            <div className="cert-data">
+              <Form form={form} initialValues={cert} labelCol={{span: 4}}>
+                <Form.Item name="serviceName"
+                           rules={[{required: true, message: '请输入域名或者名称,唯一不可重复'}]}
+                           label="域名">
+                  <Input />
+                </Form.Item>
+                <div className="inline-item" >
+                  <Form.Item name="pem"
+                             rules={[{required: true, message: '请输入或者选择证书'}]}
+                             label="pem证书">
+                    <Input.TextArea rows={8}/>
+
+                  </Form.Item>
+                  <Upload beforeUpload={(file) => onBeforeUpload("pem", file)}
+                          showUploadList={false}
+                          accept=".pem">
+                    <Button icon={<UploadOutlined/>}></Button>
+                  </Upload>
+                </div>
+                <div className="inline-item" >
+                  <Form.Item name="key"
+                             rules={[{required: true, message: '请输入或者选择私钥'}]}
+                             label="私钥">
+                    <Input.TextArea rows={8} />
+
+                  </Form.Item>
+                  <Upload beforeUpload={(file) => onBeforeUpload("key", file)}
+                          showUploadList={false}
+                          accept=".key">
+                    <Button icon={<UploadOutlined/>}></Button>
+                  </Upload>
+                </div>
+
+                <Form.Item name="remark" label="备注">
+                  <Input.TextArea />
+                </Form.Item>
+                <Form.Item className="footer-item">
+                  <Button onClick={()=>setCert(undefined)}>取消</Button>
+                  <Button loading={loading} onClick={onSubmitData} type="primary">保存</Button>
+                </Form.Item>
+              </Form>
+            </div>
+          </Drawer>
+        </div>
+      {contextHolder}
+
+    </div>)
+}
+type IProps = {
+    cert: INginxCerts
+    nginxId: number
+    onRefresh: () => void
+  modal: Omit<ModalStaticFunctions, "warn">
+}
+const DelButton = ({cert, nginxId, onRefresh, modal}: IProps)=>{
+
+    const [loading,setLoading] = useState(false)
+
+    const onDel = (e: any)=>{
+        e.preventDefault()
+        modal.confirm({
+            title: '警告',
+            content: '您确定要删除该证书信息吗?删除操作不可恢复,请谨慎操作.',
+            okType: 'danger',
+            cancelText: '取消',
+            okText: '确定',
+            onOk: ()=>{
+                setLoading(true)
+                NginxApis.delCerts(nginxId, cert.id)
+                    .then(()=>{
+                        onRefresh?.()
+                    })
+                    .finally(()=>{
+                        setLoading(false)
+                    })
+            }
+        })
+    }
+
+
+
+    return <>
+        <Button onClick={onDel}
+                loading={loading}
+                type="text"
+                danger
+                icon={<DeleteOutlined />}
+        />
+    </>
+}

+ 54 - 0
src/pages/nginx/components/EditNginxBtn.tsx

@@ -0,0 +1,54 @@
+/**
+ * @author tuonian
+ * @date 2023/6/30
+ */
+import {INginx} from "../../../models/nginx.ts";
+import {NginxActions} from "../../../store/slice/nginx.ts";
+import {useState} from "react";
+import {EditOutlined} from "@ant-design/icons";
+import {Button} from "antd";
+import {NginxApis} from "../../../api/nginx.ts";
+import {Notify} from "planning-tools";
+import {useAppDispatch} from "../../../store";
+import {useNavigate} from "react-router";
+import {nginxPrefix} from "../../../routes/routes.tsx";
+
+type IProps = {
+  nginx: INginx
+}
+export const EditNginxBtn = ({nginx}: IProps)=>{
+
+  const [loading,setLoading] = useState(false)
+  const dispatch = useAppDispatch()
+  const navigate = useNavigate()
+
+  const toNginx = ()=>{
+    setLoading(true);
+    NginxApis.getNginx(nginx.id)
+      .then(({data})=>{
+        const respData = data.data;
+        if (!respData){
+          Notify.warn('查询失败,请重试!');
+          return
+        }
+        console.log('getNginx', data)
+        dispatch(NginxActions.setCurrent({
+            nginx,
+            servers: respData.servers
+        }))
+        navigate(nginxPrefix(nginx.id))
+      })
+      .catch(e=>{
+        Notify.warn(e.msg || e.message)
+      })
+      .finally(()=>{
+        setLoading(false)
+      })
+
+
+  }
+
+  return (
+    <Button loading={loading} onClick={()=>toNginx()} type="link" icon={<EditOutlined />} />
+  )
+}

+ 97 - 0
src/pages/nginx/components/StopStartButton.tsx

@@ -0,0 +1,97 @@
+import {useEffect, useState} from "react";
+import {Button, Modal, Tag} from "antd";
+import {isNull, Message} from "planning-tools";
+import {NginxApis} from "../../../api/nginx.ts";
+import {useAppSelector} from "../../../store";
+
+/**
+ *
+ * @constructor
+ */
+export const StopStartButton = () => {
+
+    const [isRun,setIsRun] = useState<boolean>()
+    const [loading,setLoading] = useState(false)
+    const [modal,contextHolder] = Modal.useModal()
+
+    const nginx = useAppSelector(state => state.nginx.current)
+
+    const fetchStatus = () => {
+        if (!nginx){
+            return
+        }
+        setLoading(true)
+        NginxApis.status(nginx.id)
+            .then(({data})=>{
+                setIsRun(data.data)
+                console.log('status', data)
+                if (!data.msg){
+                    return
+                }
+                if (data.data){
+                    Message.success(data.msg)
+                }else {
+                    Message.warning(data.msg)
+                }
+            })
+            .finally(()=>{
+                setLoading(false)
+            })
+    }
+
+
+    const postStartOrStopApi = ()=>{
+        if (!nginx){
+            return
+        }
+        setLoading(true)
+        const request = isRun ? NginxApis.stopNginx(nginx.id) : NginxApis.startNginx(nginx.id);
+        request.then(({data})=>{
+            console.log('data', data);
+            setIsRun(data.data);
+            if (data.msg){
+                Message.warning(data.msg)
+            }
+        }).finally(()=>{
+            setLoading(false)
+        })
+    }
+    const onStartOrStop  = () => {
+        if (isNull(isRun)){
+            fetchStatus()
+            return
+        }
+        modal.confirm({
+            type: 'warning',
+            title: `您确定要${isRun ? '停止' : '启动'}nginx服务吗?`,
+            okType: 'danger',
+            okText: '确定',
+            cancelText: '取消',
+            onOk: ()=>{
+                postStartOrStopApi()
+            }
+        })
+    }
+
+    useEffect(()=>{
+        fetchStatus()
+    },[])
+
+    if (!nginx){
+        return null
+    }
+
+    return (<>
+      <span>Nginx:</span>
+      <Tag color={isNull(isRun) ? 'grey': isRun ? 'green': 'red'}>{isNull(isRun) ? '未知': isRun ? '运行中':'已停止'}</Tag>
+        <Button type={ isRun?'default' : 'primary'}
+                onClick={onStartOrStop}
+                hidden={isNull(isRun)}
+                size="small"
+                danger={isRun}
+                loading={loading}>
+            { isRun ? '停止':'启动'}
+        </Button>
+        {contextHolder}
+        </>)
+}

+ 39 - 0
src/pages/nginx/components/auth/config.json

@@ -0,0 +1,39 @@
+{
+  "form": [
+    {
+      "title": "是否启用",
+      "key": "auth_request_on",
+      "required": false,
+      "type": "switch",
+      "cascade": {
+        "true": [
+          {
+            "title": "auth_request_uri",
+            "key": "auth_request_uri",
+            "type": "string",
+            "placeholder": "输入鉴权的路由"
+          }
+        ]
+      }
+    },
+    {
+      "title": "auth_request_set",
+      "key": "auth_request_set",
+      "required": false,
+      "type": "array",
+      "items": [
+        {
+          "key": "name",
+          "title": "变量",
+          "type": "string"
+        },
+        {
+          "key": "value",
+          "title": "变量值",
+          "type": "string"
+        }
+      ],
+      "description": "Sets the request variable to the given value after the authorization request completes. The value may contain variables from the authorization request, such as $upstream_http_*."
+    }
+  ]
+}

+ 10 - 0
src/pages/nginx/components/auth/index.less

@@ -0,0 +1,10 @@
+.gzip-input{
+  margin-right: 10px;
+}
+
+.gzip-popover{
+  .auto-form{
+    min-width: 450px;
+    width: 550px;
+  }
+}

+ 40 - 0
src/pages/nginx/components/auth/index.tsx

@@ -0,0 +1,40 @@
+/**
+ * @author tuonian
+ * @date 2023/7/5
+ */
+import {AutoTypeInputProps} from 'planning-tools'
+import './index.less'
+import config from './config.json'
+import {IContentProps, NgxBasicInput, registerInput} from "../basic";
+import {KeyValue} from "../../../../models/nginx.ts";
+
+export const AuthInput = ({...props}: AutoTypeInputProps)=>{
+
+  const ShowContent = ({data}: IContentProps)=>{
+    return (<span>{data.auth_request_on ? data.auth_request_uri : '不启用'}</span>)
+  }
+
+  const renderLines = (values: any)=>{
+    const lines: string[] = []
+    if (!values.auth_request_on){
+      return lines
+    }
+    lines.push(`auth_request  ${values.auth_request_uri};`)
+    if (Array.isArray(values.auth_request_set)){
+      values.auth_request_set.forEach((item: KeyValue)=>{
+        lines.push(`auth_request_set  ${item.name}  ${item.value};`)
+      })
+    }
+    return lines;
+  }
+
+  return <NgxBasicInput
+    {...props}
+    columns={config.form}
+    renderLines={renderLines}
+    content={ShowContent}
+    />
+}
+
+registerInput('auth', AuthInput)
+

+ 19 - 0
src/pages/nginx/components/basic/index.less

@@ -0,0 +1,19 @@
+.popover-input{
+  margin-right: 10px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+
+.popover-popover{
+  .auto-form{
+    min-width: 450px;
+    width: 550px;
+  }
+  .form-btns{
+    padding-left: 25%;
+    .ant-btn+.ant-btn{
+      margin-left: 10px;
+    }
+  }
+}

+ 108 - 0
src/pages/nginx/components/basic/index.tsx

@@ -0,0 +1,108 @@
+/**
+ * @author tuonian
+ * @date 2023/7/5
+ */
+import {AdvanceInputConfigs, AutoForm, AutoTypeInputProps, FormColumnType} from 'planning-tools'
+import {Button, FormInstance, Popover} from "antd";
+import {EditOutlined} from "@ant-design/icons";
+import React, {useEffect, useState} from "react";
+import {AutoFormFooterProps} from "planning-tools/dist/esm/Components/AutoForm/form";
+import {NgxModuleData} from "../input.ts";
+import './index.less'
+
+export type OnChange = (values: any) => void
+
+export type IContentProps<T = any> = {
+  data: T
+  onChange: OnChange
+}
+
+type IProps = {
+  content: React.FC<IContentProps>
+  columns?: FormColumnType[]
+  renderLines: (values: any) => string[]
+  renderHttpLines?: (values: any) =>string[]
+  overlayClassName?: string
+  labelCol?: number
+}
+export const NgxBasicInput = (
+  {
+    content: ContentComp,
+    columns = [],
+    renderLines,
+      renderHttpLines,
+    overlayClassName,
+    labelCol = 6,
+    value, onChange
+  }: IProps & AutoTypeInputProps) => {
+
+  const [data, setData] = useState<any>({})
+  const [open, setOpen] = useState(false)
+
+  useEffect(() => {
+    if (value?.data) {
+      setData(value.data || {})
+    }
+  }, [value])
+
+  const triggerChange = (values: any) => {
+    const lines = renderLines(values)
+    onChange?.({
+      data: values,
+      lines: lines,
+      http: renderHttpLines?.(values) || []
+    } as NgxModuleData)
+  }
+
+  const onValuesChange = (values: any) => {
+    const current = {...data, ...values}
+    setData(current)
+    setOpen(false)
+    triggerChange(current)
+  }
+
+  const onSubmitData = async (form: FormInstance | null) => {
+    if (!form) {
+      return
+    }
+    const values = await form.validateFields();
+    console.log('values', values);
+    onValuesChange(values)
+  }
+
+  const renderFormFooter = ({formRef}: AutoFormFooterProps) => {
+    return (<div className="form-btns">
+      <Button onClick={() => onSubmitData(formRef.current as never)} type="primary">保存</Button>
+      <Button onClick={() => setOpen(false)}>取消</Button>
+    </div>)
+  }
+
+  const renderForm = () => {
+    return (<AutoForm columns={columns}
+                      formProps={{
+                        labelCol: {span: labelCol}
+                      }}
+                      onlyFields={true}
+                      footer={renderFormFooter}
+                      data={data}/>)
+  }
+
+  return (<div className="popover-input">
+    <ContentComp data={data} onChange={onValuesChange}/>
+    <Popover destroyTooltipOnHide
+             overlayClassName={`popover-popover ${overlayClassName || ''}`}
+             placement="right"
+             open={open}
+             onOpenChange={o => setOpen(o)}
+             trigger="click" content={renderForm}>
+      <Button type="link" onClick={() => setOpen(true)} icon={<EditOutlined/>}/>
+    </Popover>
+  </div>)
+}
+
+/**
+ * 注册自定义的输入框
+ * @param type
+ * @param Component
+ */
+export const registerInput = (type: string, Component: React.FC<AutoTypeInputProps>) => AdvanceInputConfigs[type] = Component

+ 52 - 0
src/pages/nginx/components/certs/index.tsx

@@ -0,0 +1,52 @@
+import {AdvanceInputConfigs, AutoTypeInputProps} from "planning-tools";
+import {Select} from "antd";
+import {useEffect, useState} from "react";
+import {NginxApis} from "../../../../api/nginx.ts";
+import {useAppSelector} from "../../../../store";
+import {INginxCerts} from "../../../../models/api.ts";
+/**
+ * @author tuonian
+ * @date 2023/7/4
+ */
+
+export const CertsSelect = ({value, onChange }: AutoTypeInputProps)=>{
+
+  const [loading,setLoading] = useState(false)
+  const [options,setOptions] = useState<any[]>([])
+
+  const nginx = useAppSelector(state => state.nginx.current)
+
+  const fetchCerts = ()=>{
+    if (!nginx?.id){
+      return
+    }
+    setLoading(true)
+    NginxApis.getCerts(nginx.id)
+      .then(({data})=>{
+        const list = Array.isArray(data.data) ?data.data.map((item: INginxCerts)=>{
+          return {
+            label: item.serviceName,
+            value: item.serviceName
+          }
+        }): []
+        setOptions(list)
+      })
+      .finally(()=>{
+        setLoading(false)
+      })
+
+  }
+  useEffect(()=>{
+    fetchCerts()
+  },[])
+
+
+  return (<Select loading={loading}
+                  value={value}
+                  onChange={onChange}
+                  options={options}
+                  style={{marginRight: 10}} />)
+}
+
+
+AdvanceInputConfigs['certs'] = CertsSelect

+ 53 - 0
src/pages/nginx/components/cors/config.json

@@ -0,0 +1,53 @@
+{
+  "form": [
+    {
+      "title": "域名",
+      "key": "origins",
+      "type": "select",
+      "mode": "tags",
+      "description": "配置允许跨域的域名,多个域名用map指令实现",
+      "option": [],
+      "required": false
+    },
+    {
+      "title": "请求方法",
+      "type": "select",
+      "key": "methods",
+      "required": false,
+      "option": ["GET","POST","PUT","DELETE","OPTIONS"],
+      "description": "配置允许跨域的请求方法",
+      "mode": "tags"
+    },
+    {
+      "title": "请求头",
+      "type": "select",
+      "key": "headers",
+      "required": false,
+      "option": ["Authorization","Content-Type","Accept","Origin","Cache-Control","X-Requested-With"],
+      "description": "配置允许跨域的请求头",
+      "mode": "tags"
+    },
+    {
+      "title": "拦截preflight",
+      "key": "preflight",
+      "required": false,
+      "type": "switch",
+      "description": "拦截preflight的OPTION请求,返回跨域配置"
+    },
+    {
+      "title": "允许发送凭据",
+      "key": "credentials",
+      "type": "switch",
+      "required": false,
+      "description": "可选字段,为true表示允许发送Cookie"
+    },
+    {
+      "title": "跨域缓存",
+      "key": "maxAge",
+      "required": false,
+      "type": "int",
+      "description": "配置 Access-Control-Max-Age,代表着在指定时间(秒)之内不用请求该地址的时候,不需要再进行预检请求,也就是跨域缓存。",
+      "value": 86400
+    }
+  ]
+}

+ 35 - 0
src/pages/nginx/components/cors/index.less

@@ -0,0 +1,35 @@
+.cors-page-overlay{
+  .ant-form-item-row{
+    .ant-form-item-control{
+      .ant-select{
+        width: 100%;
+        max-width: 100%;
+      }
+    }
+  }
+  .form-btns{
+    padding-left: 16%;
+  }
+}
+
+.config-status{
+  &.has-config{
+    color: #1890ff;
+  }
+}
+
+.cors-config-overlay{
+  .ant-tooltip-inner{
+    min-width: 600px;
+    padding: 2px;
+    .ant-input{
+      width: 100%;
+      min-width: 400px;
+      max-width: 100%;
+      color: white;
+      background: #000000;
+      border: none;
+    }
+  }
+
+}

+ 110 - 0
src/pages/nginx/components/cors/index.tsx

@@ -0,0 +1,110 @@
+/**
+ * @author tuonian
+ * @date 2023/7/5
+ */
+import {AutoTypeInputProps, isNull, uniqueKey} from 'planning-tools'
+import './index.less'
+import config from './config.json'
+import {IContentProps, NgxBasicInput, registerInput} from "../basic";
+import {Input, Tooltip} from "antd";
+
+type DataType = {
+  /**
+   * 多域名时的随机key
+   */
+  key?: string
+  origins?: string[]
+  methods?: string[]
+  headers?: string[]
+  preflight?: boolean
+  credentials?: boolean
+  maxAge?: number
+}
+
+export const CorsInput = ({...props}: AutoTypeInputProps)=>{
+
+  const ShowContent = ({data}: IContentProps<DataType>)=>{
+    if (data.origins?.length && data.methods?.length){
+      const lines = renderLines(data);
+      const httpLines = renderHttpLines(data)
+      let hint = ''
+      if (httpLines.length){
+        hint = '# map \n'+ httpLines.join('\n') +'\n'
+      }
+      hint +='# location\n'
+      hint += lines.join('\n')
+
+      return (<Tooltip
+          destroyTooltipOnHide
+          overlayClassName="cors-config-overlay"
+          trigger="click"
+          placement="topLeft"
+          autoAdjustOverflow
+          title={<Input.TextArea disabled rows={Math.min(10,lines.length + httpLines.length + 3)} value={hint} />}>
+        <span className="config-status has-config">已配置</span>
+      </Tooltip>)
+    }
+   return <span className="config-status">未完成配置</span>
+  }
+
+  const renderHttpLines = (values: DataType = {})=>{
+    const lines: string[] = []
+    if (!values.origins?.length){
+      return lines
+    }
+    if (values.origins.length < 2){
+      return lines
+    }
+    lines.push(`map  $http_origin ${values.key}  {`)
+    lines.push(`    default 0;`)
+    values.origins.forEach(host=>{
+      lines.push(`    "~${host}"   ${host};`)
+    })
+    lines.push(`}`)
+    return lines
+  }
+
+  const renderLines = (values: DataType = {})=>{
+    const lines: string[] = []
+    if (!values.key){
+      values.key = `$cors_${uniqueKey(20)}`
+    }
+    if (!values.origins?.length){
+      return lines
+    }
+    if (values.origins.length === 1){
+      lines.push(`add_header 'Access-Control-Allow-Origin' '${values.origins[0]}';`)
+    }else {
+      lines.push(`add_header 'Access-Control-Allow-Origin' ${values.key};`)
+    }
+
+    if (values.methods?.length){
+      lines.push(`add_header 'Access-Control-Allow-Origin' '${values.methods.join(',')}';`)
+    }
+    if (values.headers?.length){
+      lines.push(`add_header 'Access-Control-Allow-Headers' '${values.headers.join(',')}';`)
+    }
+    if (!isNull(values.credentials)){
+      lines.push(`add_header Access-Control-Allow-Credentials   '${values.credentials ? 'true': 'false'}';`)
+    }
+    if (!isNull(values.preflight)){
+      lines.push(`if ($request_method = 'OPTIONS') {
+        return 204;
+ }`)
+    }
+    return lines;
+  }
+
+  return <NgxBasicInput
+    {...props}
+    columns={config.form}
+    renderLines={renderLines}
+    renderHttpLines={renderHttpLines}
+    content={ShowContent}
+    overlayClassName="cors-page-overlay"
+    labelCol={4}
+    />
+}
+
+registerInput('cors', CorsInput)
+

+ 32 - 0
src/pages/nginx/components/error/config.json

@@ -0,0 +1,32 @@
+{
+  "form": [
+    {
+      "title": "错误页面",
+      "type": "array",
+      "key": "error_pages",
+      "required": false,
+      "items": [
+        {
+          "title": "错误码",
+          "key": "codes",
+          "type": "select",
+          "option": ["502","401","403","503"],
+          "mode": "tags"
+        },
+        {
+          "title": "响应码",
+          "key": "respCode",
+          "type": "int",
+          "required": false,
+          "description": "重定向错误页面后,返回给前端的HTTP状态码,比如:404,200,301"
+        },
+        {
+          "title": "跳转uri",
+          "key": "uri",
+          "type": "string",
+          "description": "当错误发生时,跳转的路由或者页面"
+        }
+      ]
+    }
+  ]
+}

+ 2 - 0
src/pages/nginx/components/error/index.less

@@ -0,0 +1,2 @@
+.error-page-overlay{
+}

+ 73 - 0
src/pages/nginx/components/error/index.tsx

@@ -0,0 +1,73 @@
+/**
+ * @author tuonian
+ * @date 2023/7/5
+ */
+import {AutoTypeInputProps} from 'planning-tools'
+import './index.less'
+import config from './config.json'
+import {IContentProps, NgxBasicInput, registerInput} from "../basic";
+
+type ErrorPageData = {
+  error_pages?: {
+    /**
+     * 处理的错误状态码
+     */
+    codes: string[]
+    /**
+     * 响应状态码
+     */
+    respCode?: string
+
+    /**
+     * 错误路由,或者命名路由
+     */
+    uri: string
+  }[]
+}
+
+export const ErrorPageInput = ({...props}: AutoTypeInputProps)=>{
+
+  const ShowContent = ({data}: IContentProps<ErrorPageData>)=>{
+    const lines = renderLines(data);
+    return (<div className="error-pages">
+      {
+      lines.map((line,index)=>(<div className="error-page-item" key={index}>{line}</div>))
+      }
+      {
+        lines.length ? null : '未配置'
+      }
+    </div>)
+  }
+
+  const renderLines = (values: ErrorPageData = {})=>{
+    const lines: string[] = []
+    if (!values.error_pages || !values.error_pages.length){
+      return lines
+    }
+    values.error_pages.forEach(item=>{
+      if (!item.codes || item.codes.length ===0 || !item.uri){
+        lines.push(`#error_page code or uri is empty, skip`)
+        return
+      }
+      let text = `error_page  ${item.codes.join(' ')}`
+      if (item.respCode){
+        text += `  =${item.respCode}`
+      }
+      text +=`   ${item.uri};`
+      lines.push(text)
+    })
+    return lines;
+  }
+
+  return <NgxBasicInput
+    {...props}
+    columns={config.form}
+    renderLines={renderLines}
+    content={ShowContent}
+    overlayClassName="error-page-overlay"
+    labelCol={0}
+    />
+}
+
+registerInput('error_page', ErrorPageInput)
+

+ 64 - 0
src/pages/nginx/components/gzip/config.json

@@ -0,0 +1,64 @@
+{
+  "form": [
+    {
+      "title": "gzip_types",
+      "key": "gzip_types",
+      "required": false,
+      "type": "select",
+      "mode": "tags",
+      "option": ["text/html","application/javascript"],
+      "description": "Syntax:\tgzip_types mime-type ...;\nDefault:\t\ngzip_types text/html;\nEnables gzipping of responses for the specified MIME types in addition to “text/html”. The special value “*” matches any MIME type (0.8.29). Responses with the “text/html” type are always compressed."
+    },
+    {
+      "title": "gzip_buffers",
+      "key": "gzip_buffers",
+      "required": false,
+      "type": "string",
+      "description": "Syntax:gzip_buffers number size;\nDefault:gzip_buffers 32 4k|16 8k;\nSets the number and size of buffers used to compress a response. By default, the buffer size is equal to one memory page. This is either 4K or 8K, depending on a platform."
+    },
+    {
+      "title": "gzip_comp_level",
+      "key": "gzip_comp_level",
+      "required": false,
+      "type": "int",
+      "description": "Syntax:gzip_comp_level level;\nDefault:\ngzip_comp_level 1;\nSets a gzip compression level of a response. Acceptable values are in the range from 1 to 9.\n"
+    },
+    {
+      "title": "gzip_disable",
+      "key": "gzip_disable",
+      "required": false,
+      "type": "string",
+      "description": "Syntax:\tgzip_disable regex ...;\nDisables gzipping of responses for requests with “User-Agent” header fields matching any of the specified regular expressions."
+    },
+    {
+      "title": "gzip_http_version",
+      "key": "gzip_http_version",
+      "required": false,
+      "type": "select",
+      "option": ["1.0","1.1"],
+      "description": "Syntax:\tgzip_http_version 1.0 | 1.1;\nDefault:\t\ngzip_http_version 1.1;"
+    },
+    {
+      "title": "gzip_min_length",
+      "key": "gzip_min_length",
+      "required": false,
+      "type": "int",
+      "description": "Syntax:\tgzip_min_length length;\nDefault:\t\ngzip_min_length 20;\nSets the minimum length of a response that will be gzipped. The length is determined only from the “Content-Length” response header field."
+    },
+    {
+      "title": "gzip_proxied",
+      "key": "gzip_proxied",
+      "required": false,
+      "type": "select",
+      "option": ["off","expired","no-cache","no-store","private","no_last_modified","no_etag","auth","any"],
+      "description": "Syntax:\tgzip_proxied off | expired | no-cache | no-store | private | no_last_modified | no_etag | auth | any ...;\nDefault:\t\ngzip_proxied off;\nEnables or disables gzipping of responses for proxied requests depending on the request and response. The fact that the request is proxied is determined by the presence of the “Via” request header field. The directive accepts multiple parameters:off\ndisables compression for all proxied requests, ignoring other parameters;\nexpired\nenables compression if a response header includes the “Expires” field with a value that disables caching;\nno-cache\nenables compression if a response header includes the “Cache-Control” field with the “no-cache” parameter;\nno-store\nenables compression if a response header includes the “Cache-Control” field with the “no-store” parameter;\nprivate\nenables compression if a response header includes the “Cache-Control” field with the “private” parameter;\nno_last_modified\nenables compression if a response header does not include the “Last-Modified” field;\nno_etag\nenables compression if a response header does not include the “ETag” field;\nauth\nenables compression if a request header includes the “Authorization” field;\nany\nenables compression for all proxied requests."
+    },
+    {
+      "title": "gzip_vary",
+      "key": "gzip_vary",
+      "required": false,
+      "type": "switch",
+      "description": "Syntax:\tgzip_vary on | off;\nDefault:\t\ngzip_vary off;\nEnables or disables inserting the “Vary: Accept-Encoding” response header field if the directives gzip, gzip_static, or gunzip are active."
+    }
+  ]
+}

+ 10 - 0
src/pages/nginx/components/gzip/index.less

@@ -0,0 +1,10 @@
+.gzip-input{
+  margin-right: 10px;
+}
+
+.gzip-popover{
+  .auto-form{
+    min-width: 450px;
+    width: 550px;
+  }
+}

+ 97 - 0
src/pages/nginx/components/gzip/index.tsx

@@ -0,0 +1,97 @@
+/**
+ * @author tuonian
+ * @date 2023/7/5
+ */
+import {AdvanceInputConfigs, AutoForm, AutoTypeInputProps, isNull} from 'planning-tools'
+import {Button, FormInstance, Popover, Switch} from "antd";
+import './index.less'
+import {EditOutlined} from "@ant-design/icons";
+import {useEffect, useState} from "react";
+import config from './config.json'
+import {AutoFormFooterProps} from "planning-tools/dist/esm/Components/AutoForm/form";
+import {isBoolean} from "lodash";
+import {NgxModuleData} from "../input.ts";
+
+export const GzipInput = ({value, onChange}:AutoTypeInputProps) => {
+
+  const [data,setData] = useState<any>({})
+  const [open,setOpen] = useState(false)
+
+  useEffect(()=>{
+    if (value?.data){
+      setData(value.data || {})
+    }
+  },[value])
+
+  const onSwitch = (checked: boolean)=>{
+    const values = { ...data,gzip: checked}
+    setData(values)
+    triggerChange(values)
+  }
+
+  const triggerChange = (values: any)=>{
+    const lines:string[] = []
+    if (data?.gzip){
+      lines.push(`gzip      on;`)
+      Object.keys(values).forEach(k=>{
+        let v = values[k];
+        if (isNull(v) || k ==='gzip'){
+          return
+        }
+        if (Array.isArray(v)){
+          v = v.join(' ')
+        }else if (isBoolean(v)){
+          v = v ? 'on' : 'off'
+        }
+        lines.push(`${k}  ${v};`)
+      })
+    }
+    onChange?.({
+      data: values,
+      lines
+    } as NgxModuleData)
+  }
+
+  const onSubmitData =async (form: FormInstance | null)=>{
+    if (!form){
+      return
+    }
+    const values = await form.validateFields();
+    console.log('values', values);
+    const current = { ...data, ...values }
+    setData(current)
+    setOpen(false)
+    triggerChange(current)
+  }
+
+  const renderFormFooter = ({formRef}:AutoFormFooterProps)=>{
+    return (<div style={{textAlign: 'center'}}>
+      <Button onClick={()=>onSubmitData(formRef.current as never)} type="primary">保存</Button>
+      <Button onClick={()=>setOpen(false)}>取消</Button>
+    </div>)
+  }
+
+  const renderForm = ()=>{
+    return (<AutoForm columns={config.form}
+                      formProps={{
+                        labelCol: {span: 6}
+                      }}
+                      onlyFields={true}
+                      footer={renderFormFooter}
+                      data={data} />)
+  }
+
+  return (<div className="gzip-input">
+    <Switch onChange={onSwitch} checked={data.gzip} />
+    <Popover destroyTooltipOnHide overlayClassName="gzip-popover"
+             placement="right"
+             open={open}
+             onOpenChange={o=>setOpen(o)}
+             trigger="click" content={renderForm}>
+      <Button onClick={()=>setOpen(true)} hidden={!data.gzip} type="link" icon={<EditOutlined />} />
+    </Popover>
+
+  </div>)
+}
+
+AdvanceInputConfigs['gzip'] = GzipInput

+ 9 - 0
src/pages/nginx/components/index.ts

@@ -0,0 +1,9 @@
+import './proxy/index.tsx'
+import './gzip/index.tsx'
+import './auth'
+import './location'
+import './certs'
+import './proxypass'
+import './proxypass/stream.tsx'
+import './error'
+import './cors'

+ 28 - 0
src/pages/nginx/components/input.ts

@@ -0,0 +1,28 @@
+import {isObject} from "planning-tools";
+
+/**
+ * 自定义的模块化输入框的数据格式
+ */
+export type NgxModuleData<D=any> = {
+  data: D
+  lines?: string[]
+  /**
+   * 渲染到http模块,也就是跟server同级别,没想好怎么搞
+   */
+  http?: string[]
+  /**
+   * 指定为false,则跳过渲染
+   */
+  enable?: boolean
+}
+
+/**
+ * 是否是自定义的nginx的输入框
+ * @param value
+ */
+export const isNgxModuleValue = (value: any)=>{
+  if (!isObject(value)){
+    return false
+  }
+  return !!Array.isArray((value as NgxModuleData)?.lines);
+}

+ 272 - 0
src/pages/nginx/components/location/config.json

@@ -0,0 +1,272 @@
+{
+  "form": [
+    {
+      "key": "name",
+      "type": "string",
+      "title": "名称",
+      "placeholder": "输入名称方便辨别",
+      "required": false
+    },
+    {
+      "key": "match",
+      "type": "object",
+      "title": "匹配路径",
+      "hideHeader": true,
+      "items": [
+        {
+          "type": "select",
+          "title": "匹配规则",
+          "option": [
+            {
+              "label": "精确匹配",
+              "value": "="
+            },
+            {
+              "label": "以字符开头",
+              "value": "^~"
+            },
+            {
+              "label": "正则(区分大小写)",
+              "value": "~"
+            },
+            {
+              "label": "正则(不区分大小写)",
+              "value": "~*"
+            },{
+              "label": "默认",
+              "value": ""
+            }
+          ],
+          "required": false,
+          "key": "regex",
+          "placeholder": "匹配规则,如=,~^",
+          "width": 180
+        },
+        {
+          "type": "string",
+          "title": "路径",
+          "key": "path",
+          "placeholder": "请输入路径",
+          "value": "/",
+          "width": 300
+        }
+      ],
+      "description": "优选级:精确匹配(=) > 完整路径 > 以字符开头(^~) > 正则顺序(~或者~*) > 部分起始路径(/xx/) > 默认路径(/);\n当有正则时,变量$1,$2为正则中匹配的()内的顺序内容,比如:~ \/(d+)\/([0-9]+),当访问/abc/123时,$1为abc,$2为123"
+    },
+    {
+      "key": "enable",
+      "title": "启用",
+      "type": "switch",
+      "description": "是否启用,如果不启用,将不会渲染该配置"
+    },
+    {
+      "key": "proxy_type",
+      "title": "代理类型",
+      "type": "select",
+      "option": [
+        {
+        "label": "反向代理",
+        "value": "proxy"
+      },{
+        "label": "静态资源",
+        "value": "static"
+      },{
+        "label": "其它",
+        "value": "other"
+      }],
+      "cascade": {
+        "proxy": [
+          {
+            "key": "proxy_pass",
+            "title": "代理地址",
+            "type": "proxy_pass"
+          },
+          {
+            "key": "proxy_settings",
+            "title": "更多代理设置",
+            "type": "proxy_settings",
+            "required": false,
+            "description": "更多代理设置"
+          }
+        ],
+        "static": [
+          {
+            "key": "index",
+            "title": "首页",
+            "required": false,
+            "type": "select",
+            "mode": "tags",
+            "option": ["index.html","index.php","index.htm"],
+            "description": "nginx静态资源默认的首页文件名,比如index.html index.php"
+          },
+          {
+            "key": "root",
+            "type": "string",
+            "title": "根路径",
+            "required": false,
+            "description": "静态资源的根路径,查找方式为直接拼接,比如:匹配路径为 /test/ ,root为/data/root,则查找资源的完整路径为:/data/root/test"
+          },
+          {
+            "key": "alias",
+            "type": "string",
+            "title": "路径别名",
+            "required": false,
+            "description": "alias和root二选一,注意与root的区别,比如:匹配路径为 /test/ ,alias为/data/root,则查找资源的完整路径为:/data/root/"
+          },
+          {
+            "key": "try_files",
+            "title": "try_files",
+            "type": "select",
+            "mode": "tags",
+            "option": ["$uri","$uri/","/index.html"],
+            "description": "",
+            "required": false,
+            "width": 450
+          }
+        ],
+        "other": [
+          {
+            "key": "return",
+            "title": "return",
+            "description": "直接返回固定内容",
+            "type": "object",
+            "hideHeader": true,
+            "required": false,
+            "items": [
+              {
+                "key": "code",
+                "type": "int",
+                "min": 200,
+                "max": 600,
+                "placeholder": "请输入http状态码",
+                "width": 180
+              },
+              {
+                "key": "content",
+                "type": "string",
+                "title": "内容",
+                "placeholder": "响应内容",
+                "width": 300
+              }
+            ]
+          }
+        ]
+      }
+    },
+    {
+      "key": "cors_setting",
+      "title": "跨域配置",
+      "type": "cors",
+      "description": "跨域配置,可以通过该配置项解决前端跨域问题",
+      "required": false
+    },
+    {
+      "type": "auth",
+      "title": "鉴权",
+      "key": "auth_request",
+      "required": false,
+      "description": "ngx_http_auth_request_module:实现了基于一子请求的结果的客户端的授权。如果子请求返回2xx响应码,则允许访问。如果它返回401或403,则访问被拒绝并显示相应的错误代码。子请求返回的任何其他响应代码都被认为是错误的"
+    },
+    {
+      "type": "gzip",
+      "title": "压缩配置",
+      "key": "gzip",
+      "required": false,
+      "description": "gzip"
+    },
+    {
+      "key": "add_header",
+      "title": "添加响应头",
+      "type": "array",
+      "hideHeader": true,
+      "description": "添加http响应头",
+      "required": false,
+      "items": [
+        {
+          "key": "name",
+          "title": "header名称",
+          "type": "string",
+          "width": 180,
+          "placeholder": "header名称"
+        },
+        {
+          "key": "value",
+          "title": "header值",
+          "type": "string",
+          "placeholder": "header值",
+          "width": 300
+        }
+      ]
+    },
+    {
+      "key": "rewrite",
+      "type": "object",
+      "title": "rewrite",
+      "required": false,
+      "description": "rewrite:对访问路径进行,放在server{}, if{},location{}段中,rewrite < regex > < replacement > [flag], 必须填写正则表达式和跳转路径才能生效",
+      "items": [
+        {
+          "key": "regex",
+          "title": "正则表达式",
+          "type": "string",
+          "width": 180,
+          "description": "必须填写正则表达式和跳转路径才能生效"
+        },
+        {
+          "key": "replacement",
+          "title": "跳转路径",
+          "type": "string",
+          "placeholder": "跳转后的内容",
+          "width": 300
+        },
+        {
+          "width": 120,
+          "key": "flag",
+          "title": "flag",
+          "type": "select",
+          "option": ["last","break","redirect","permanent"],
+          "description": "last: 相当于Apache的【L】标记,表示完成rewrite;\nbreak:本条规则匹配完成即终止,不在匹配后面的任何规则;\nredirect: 返回302临时重定向,浏览器地址栏会显示跳转后的URL地址,爬虫不会更新url;\npermanent:返回301永久重定向,浏览器地址栏会显示跳转后的URL地址,爬虫更新url;"
+        }
+      ]
+    },
+    {
+      "key": "tmp_custom_config",
+      "title": "自定义配置",
+      "type": "textarea",
+      "hideHeader": true,
+      "description": "自定义配置,注意,每行结尾需要加“;”号,将会拼接在最后",
+      "required": false,
+      "trim": false
+    },
+    {
+      "key": "internal",
+      "title": "内部路由",
+      "type": "switch",
+      "description": "内部路由:nginx内部访问,一旦出了这个配置文件,则失效"
+    },
+    {
+      "key": "error_page",
+      "title": "错误页面",
+      "type": "error_page",
+      "required": false,
+      "description": "错误页面配置"
+    },
+    {
+      "key": "default_type",
+      "title": "默认内容类型",
+      "type": "string",
+      "required": false,
+      "description": "default_type eg. text/plain application/json"
+    },
+    {
+      "key": "remark",
+      "title": "备注信息",
+      "type": "textarea",
+      "rows": 3,
+      "placeholder": "输入备注信息",
+      "required": false,
+      "trim": false,
+      "width": 600
+    }
+  ]
+}

+ 42 - 0
src/pages/nginx/components/location/index.less

@@ -0,0 +1,42 @@
+.location-input{
+
+  .ant-drawer-header{
+    padding: 5px 10px;
+  }
+  .ant-drawer-body{
+    padding: 10px;
+  }
+}
+
+.location-table{
+  .ant-table-cell{
+    padding: 10px;
+  }
+  .location-btns{
+    .ant-btn+.ant-btn{
+      margin-left: 5px;
+    }
+    .ant-btn{
+      padding: 2.4px 0;
+    }
+    .ant-btn-link{
+      font-size: 13px;
+    }
+  }
+
+}
+.location-table ~ .description{
+  display: block;
+  width: 100%;
+  box-sizing: border-box;
+  padding-right: 20px;
+}
+
+.location-conf-preview{
+  width: 520px;
+  min-height: 100px;
+  .ant-input[disabled]{
+    color: #333333;
+    background: none;
+  }
+}

+ 281 - 0
src/pages/nginx/components/location/index.tsx

@@ -0,0 +1,281 @@
+import {Button, Drawer, Input, Modal, Popover, Space, Switch, Table} from "antd";
+import {ColumnsType} from "antd/es/table";
+import {
+    AdvanceInputConfigs,
+    AutoForm,
+    AutoFormInstance,
+    AutoTypeInputProps,
+    isNull,
+    Message,
+    uniqueKey
+} from "planning-tools";
+import {useEffect, useRef, useState} from "react";
+import {CopyOutlined, DeleteOutlined, EditOutlined, PlusOutlined} from "@ant-design/icons";
+import {INginxLocation} from "../../../../models/nginx.ts";
+import {cloneDeep} from "lodash";
+import FormConfig from './config.json'
+
+import './index.less'
+import {SiteInput} from "../site";
+import {renderLocation} from "./utils.ts";
+
+/**
+ * 部分的重要信息
+ * @param data
+ * @constructor
+ */
+const LocationInfo = ({data}:{ data: INginxLocation})=>{
+
+    const rootDir = ()=>{
+        if (data.alias){
+            return `alias: ${data.alias}`
+        }
+        return `root: ${data.root || '--'}`
+    }
+
+
+    return (<div>
+        {
+            data.proxy_type === 'proxy' ? <div>{`proxy: ${data.proxy_pass}`}</div> : null
+        }
+        {
+            data.proxy_type === 'static' ? <div>{rootDir()}<SiteInput location={data} /></div>:null
+        }
+        <div>
+            {
+                data.rewrite?.regex && data.rewrite?.replacement ? `${data.rewrite.regex} ${data.rewrite.replacement}` : ''
+            }
+        </div>
+    </div>)
+}
+
+/**
+ * 路由,站点,规则编辑
+ * @param value
+ * @param onChange
+ * @param column
+ * @constructor
+ */
+export const LocationInput = ({value, onChange }: AutoTypeInputProps) => {
+
+    const [locations, setLocations] = useState<INginxLocation[]>([])
+
+    const [editData, setEditData] = useState<INginxLocation>()
+    const isAddRef = useRef(false)
+
+    const [modal,contextHolder] = Modal.useModal()
+
+    const formRef = useRef<AutoFormInstance>()
+
+    useEffect(() => {
+        if (Array.isArray(value)) {
+            setLocations(value.map((item: INginxLocation) => {
+                if (!item.id) {
+                    item.id = uniqueKey(20)
+                }
+                if (!item.lines){
+                    renderLocation(item)
+                }
+                return item
+            }))
+        }
+
+    }, [value])
+
+    const onEditRow = (data: INginxLocation) => {
+        isAddRef.current = false
+        setEditData(cloneDeep(data))
+    }
+
+    const onAddData = (data?: INginxLocation, index?: number)=>{
+        isAddRef.current = true
+        setEditData({ ...data,id: uniqueKey(20),__index__: index} as never)
+    }
+
+    const onRemoveData = (data: INginxLocation)=>{
+        const onOk = ()=>{
+            const list = locations.filter(item=>item.id !== data.id);
+            onChange?.(list)
+        }
+        modal.confirm({
+            title: '提示',
+            type: 'warning',
+            content: '您确定要删除该代理/站点吗?删除操作不可恢复,请谨慎操作!',
+            okType: 'danger',
+            okText: '仍要删除',
+            cancelText: '先不了',
+            onOk,
+        })
+    }
+
+    const onQuickChangeStatus = (data: INginxLocation, enable: boolean) => {
+        const list = locations.map(item=>{
+            if (item.id === data.id){
+                return { ...item, enable }
+            }
+            return item
+        })
+        onChange?.(list)
+    }
+
+    const onSubmitData = async () => {
+        const values = await formRef.current?.onSyncSubmit(true);
+        const newData = {...editData, ...values} as INginxLocation;
+        console.log('newLocation', newData);
+        if (!editData) {
+            console.warn('editData is null ,skip ');
+            return
+        }
+        renderLocation(newData)
+        let list: INginxLocation[]
+        if (isAddRef.current){
+            const index = newData.__index__ || locations.length;
+            delete newData.__index__;
+            let exist = locations.find(item=>item.name == newData.name);
+            if (exist){
+                Message.warning('名称不能相同,请修改后再保存!');
+                return
+            }
+            exist = locations.find(item=> {
+                if (item.match && newData.match){
+                    return item.match.regex === newData.match.regex && item.match.path === newData.match.path
+                }
+                return false
+            });
+            if (exist){
+                Message.warning('匹配规则不能完全一样,请修改后重新添加!');
+                return
+            }
+            renderLocation(newData);
+            if (isNull(index) || index < 0 || index >= locations.length-1){
+                list = locations.concat([newData])
+            }else {
+                list = []
+                locations.forEach((item,idx)=>{
+                    if (idx === index){
+                        list.push(item)
+                        list.push(newData)
+                    }else {
+                        list.push(item)
+                    }
+                })
+            }
+        }else {
+            renderLocation(newData);
+            list = locations.map(item => {
+                if (item.id === newData.id) {
+                    return {...item, ...newData}
+                }
+                return item
+            })
+        }
+        onChange?.(list)
+        setEditData(undefined)
+    }
+
+    const renderPreview = (data: INginxLocation)=>{
+        let content ='';
+        let rows = 0;
+        if (data.http?.length){
+           content = data.http.join('\n') + '\n';
+           rows = data.http.length;
+        }
+        if (data.lines){
+            content = content+ data.lines.join('\n')
+            rows +=data.lines.length
+        }
+        return (<div className="location-conf-preview">
+            <Input.TextArea rows={Math.max(Math.min(10,rows),5)} disabled value={content} />
+        </div>)
+    }
+
+    const renderOps = (_: never, data: INginxLocation, index: number) => {
+        return (
+            <div className="location-btns">
+                <Button onClick={() => onRemoveData(data)} type="text" danger icon={<DeleteOutlined/>}/>
+                <Button onClick={() => onEditRow(data)} type="link" icon={<EditOutlined/>}/>
+                <Button onClick={()=>onAddData(data, index)} type="link" icon={<CopyOutlined/>}/>
+                <Popover trigger="click" destroyTooltipOnHide
+                         placement="top"
+                         content={()=>renderPreview(data as never)} >
+                    <Button type="link">预览</Button>
+                </Popover>
+            </div>
+        )
+    }
+
+    const columns: ColumnsType = [
+        {
+            dataIndex: 'name',
+            title: '路由名称',
+            width: 120
+        },
+        {
+            dataIndex: 'match',
+            title: "规则",
+            render: (value) => <span>{`${value.regex || ''} ${value.path}`}</span>
+        },
+        {
+            dataIndex: 'enable',
+            title: '状态',
+            render: (value,record) => <Switch onChange={c=>onQuickChangeStatus(record as never,c)} checked={value}/>
+        },
+        {
+            dataIndex: 'proxy_pass',
+            title: '代理或路径',
+            render: (_,record: any)=>{
+                return (<LocationInfo data={record} />)
+            }
+        },
+        {
+          dataIndex: 'remark',
+          title:"备注",
+        },
+        {
+            title: '操作',
+            render: renderOps as never,
+            width: 180,
+            fixed: 'right'
+        }
+    ]
+
+    return (
+        <>
+
+            {
+                locations.length ? (<Table pagination={false}
+                                           style={{marginRight: 5}}
+                                           rowKey="id"
+                                           columns={columns as never}
+                                           className="location-table"
+                                           dataSource={locations}>
+                    <div>Empty</div>
+                </Table>) : (
+                    <>
+                        <Button onClick={()=>onAddData()} className="add-btn" type="link" icon={<PlusOutlined/>}/>
+                    </>
+                )
+            }
+            <Drawer title={isAddRef.current? '新增' : '编辑'}
+                    placement="right"
+                    open={!!editData}
+                    onClose={() => setEditData(undefined)}
+                    destroyOnClose
+                    width={900}
+                    className="location-input"
+                    extra={<Space>
+                        <Button onClick={onSubmitData} ghost type="primary">保存</Button>
+                    </Space>}
+            >
+                <AutoForm
+                    columns={FormConfig.form}
+                    ref={formRef as never}
+                    data={editData}/>
+            </Drawer>
+            {contextHolder}
+        </>
+    )
+}
+
+
+AdvanceInputConfigs['locations'] = LocationInput

+ 108 - 0
src/pages/nginx/components/location/utils.ts

@@ -0,0 +1,108 @@
+import {INginxLocation} from "../../../../models/nginx.ts";
+import {cloneDeep} from "lodash";
+import {isNgxModuleValue, NgxModuleData} from "../input.ts";
+import {isBasicData} from "planning-tools";
+
+/**
+ * 临时数据,不渲染
+ */
+const blacklist: { [key:string]:boolean } = {
+    'rewrite': true,
+    'add_header': true,
+    'id': true,
+    'name': true,
+    'proxy_type': true,
+    'enable': true,
+    "remark": true,
+    "nginxId": true,
+    "proxy_set_header": true,
+    "__index__": true,
+    "internal": true,
+    "lines": true,
+    http:true,
+    data: true
+}
+
+/**
+ * 这里暂时要考虑下
+ * @param origin
+ * @param httpLines
+ */
+export const renderLocation = (origin: INginxLocation) => {
+    const loc = cloneDeep(origin)
+    const lines: string[] = [];
+    const httpLines: string[] = [];
+    origin.lines = lines;
+    origin.http = httpLines;
+
+    lines.push('')
+    lines.push(`####   ${loc.name || loc.id} start...`)
+    lines.push(`location ${loc.match.regex || ''} ${loc.match.path || '/'} {`)
+
+    if (loc.rewrite && loc.rewrite.replacement && loc.rewrite.regex){
+        lines.push(`    rewrite ${loc.rewrite.regex} ${loc.rewrite.replacement} ${loc.rewrite.flag || ''};`)
+    }
+
+    (loc.add_header || []).forEach(h=>{
+        lines.push(`    add_header ${h.name} ${h.value};`)
+    })
+
+    if (loc.proxy_type !=='proxy'){
+        delete loc.proxy_settings
+        delete loc.proxy_pass
+        delete loc.proxy_set_header
+        console.log('loc', loc)
+    }
+
+    if (loc.proxy_type !== 'static'){
+        delete loc.root
+        delete loc.alias
+    }
+    if (loc.proxy_type !== 'other'){
+        delete loc.return
+    }
+
+    if (loc.internal){
+        lines.push('    internal;');
+    }
+    delete loc.internal;
+
+    Object.keys(loc).forEach(k=>{
+        if (blacklist[k]){
+            return;
+        }
+        if (k.startsWith("tmp" || k.startsWith("temp"))){
+            return;
+        }
+        let value = (loc as any)[k];
+        if (Array.isArray(value)){
+            value = value.join(' ')
+        }else if (isNgxModuleValue(value)){
+            value.lines.forEach((line: string)=>{
+                lines.push(`    ${line}`)
+            });
+            (value as NgxModuleData).http?.forEach(l=>httpLines.push(l))
+            value = '';
+        } else if (!isBasicData(value)){
+            console.log('[render] skip',k, value)
+            value = ''
+        }
+        if (value){
+            lines.push(`    ${k}      ${value};`)
+        }
+    })
+
+    if (loc.tmp_custom_config){
+        loc.tmp_custom_config.split('\n').forEach(line=>{
+            lines.push(`    ${line}`)
+        })
+    }
+    if (loc.return && loc.return.code){
+        lines.push(`    return  ${loc.return.code}  ${loc.return.content};`)
+    }
+
+    lines.push('}')
+    lines.push(`####   ${loc.name || loc.id} end...`)
+    lines.push('')
+    return lines
+}

+ 195 - 0
src/pages/nginx/components/proxy/config.json

@@ -0,0 +1,195 @@
+{
+  "form": [
+    {
+      "key": "tmp_trans_ip",
+      "title": "透传客户端IP",
+      "type": "switch",
+      "required": false,
+      "description": "添加:proxy_set_header X-Real_IP $remote_addr,X-Forwarded-For $proxy_add_x_forwarded_for"
+    },
+    {
+      "key": "tmp_trans_host",
+      "title": "改写访问域名",
+      "type": "switch",
+      "required": false,
+      "description": "添加:proxy_set_header Host $host"
+    },
+    {
+      "key": "tmp_support_ws",
+      "title": "支持Websocket",
+      "type": "switch",
+      "required": false,
+      "description": "添加websocket代理需要的请求头"
+    },
+    {
+      "key": "proxy_connect_timeout",
+      "title": "连接超时时间",
+      "type": "string",
+      "required": false,
+      "ruleType": "reg",
+      "pattern": "^(\\d+(s|m|h))?$",
+      "description": "eg. 60s 5m 1h"
+    },
+    {
+      "key": "proxy_read_timeout",
+      "title": "读超时时间",
+      "type": "string",
+      "ruleType": "reg",
+      "pattern": "[\\d+](s|m|h)$",
+      "required": false,
+      "description": "proxy_read_timeout"
+    },
+    {
+      "key": "proxy_http_version",
+      "title": "代理http版本",
+      "type": "select",
+      "option": ["1.0","1.1"],
+      "required": false
+    },
+    {
+      "key": "proxy_set_header",
+      "title": "代理请求头",
+      "type": "array",
+      "items": [
+        {
+          "key": "name",
+          "type": "string",
+          "placeholder": "请求头名称",
+          "title": "请求头名称",
+          "mode": "tags",
+          "option": ["Host","X-Real-IP","X-Forwarded-For","Upgrade","Connection"],
+          "description": "Host,X-Real-IP,X-Forwarded-For,Upgrade,Connection"
+        },
+        {
+          "key": "value",
+          "type": "string",
+          "mode": "tags",
+          "placeholder": "请求头值",
+          "title": "请求头值",
+          "option": ["$host","$remote_addr","$proxy_add_x_forwarded_for","$http_upgrade","upgrade"],
+          "description": "如:$host,$remote_addr,$proxy_add_x_forwarded_for,$http_upgrade,upgrade"
+        }
+      ],
+      "required": false
+    },
+    {
+      "key": "proxy_next_upstream",
+      "title": "proxy_next_upstream",
+      "type": "select",
+      "option": ["error","timeout","invalid_header","http_500","http_502","http_503","http_504","http_403","http_404","http_429","non_idempotent","off"],
+      "mode": "multiple",
+      "description": "default: proxy_next_upstream error timeout",
+      "required": false,
+      "width": 425
+    },
+    {
+      "key": "proxy_next_upstream_timeout",
+      "title": "next_upstream_timeout",
+      "type": "string",
+      "required": false,
+      "description": "eg. 60s 5m"
+    },
+    {
+      "key": "proxy_next_upstream_tries",
+      "title": "proxy_next_upstream_tries",
+      "type": "int",
+      "required": false,
+      "description": "proxy_next_upstream_tries,default is 0"
+    },
+    {
+      "key": "proxy_custom_config",
+      "title": "自定义配置",
+      "type": "textarea",
+      "hideHeader": true,
+      "description": "参考文档: https://nginx.org/en/docs/http/ngx_http_proxy_module.html",
+      "required": false,
+      "placeholder": "将会拼接到http的配置文件后,请注意格式",
+      "width": 425
+    },
+    {
+      "key": "tmp_proxy_more",
+      "title": "更多设置",
+      "collapsible": true,
+      "type": "divider",
+      "items": [
+        {
+          "key": "proxy_send_timeout",
+          "title": "proxy_send_timeout",
+          "type": "string",
+          "ruleType": "reg",
+          "pattern": "[\\d+](s|m|h)$",
+          "required": false
+        },
+        {
+          "key": "proxy_redirect",
+          "title": "重定向(proxy_redirect)",
+          "type": "string",
+          "required": false,
+          "description": "请输入 default 或者off 或者 redirect replacement,eg. http://upstream:port/two/ /one/"
+        },
+        {
+          "key": "proxy_pass_request_body",
+          "title": "发送请求数据",
+          "description": "proxy_pass_request_body: 特定场景,不需要将数据转发到服务端,默认发送;关闭会同时添加 proxy_set_header Content-Length \"\" ",
+          "type": "switch",
+          "value": true,
+          "required": false
+        },
+        {
+          "key": "ssl_certificate",
+          "title": "SSL证书",
+          "type": "certs",
+          "required": false
+        },
+        {
+          "key": "proxy_ssl_ciphers",
+          "title": "proxy_ssl_ciphers",
+          "type": "string",
+          "required": false
+        },
+        {
+          "key": "proxy_ssl_protocols",
+          "title": "proxy_ssl_protocols",
+          "type": "select",
+          "option": ["SSLv2","SSLv3","SSLv1","SSLv1.1","SSLv1.2","SSLv1.3"],
+          "mode": "multiple",
+          "required": false
+        },
+        {
+          "key": "proxy_ssl_verify",
+          "title": "证书校验",
+          "type": "switch",
+          "required": false
+        },
+        {
+          "key": "proxy_store",
+          "title": "proxy_store",
+          "type": "string",
+          "required": false,
+          "description": "Enables saving of files to a disk. The on parameter saves files with paths corresponding to the directives alias or root. The off parameter disables saving of files. In addition, the file name can be set explicitly using the string with variables:\n\nproxy_store /data/www$original_uri;"
+        },
+        {
+          "key": "proxy_store_access",
+          "title": "proxy_store_access",
+          "type": "string",
+          "required": false,
+          "description": "Sets access permissions for newly created files and directories, e.g.:\n\nproxy_store_access user:rw group:rw all:r;\nIf any group or all access permissions are specified then user permissions may be omitted:\n\nproxy_store_access group:rw all:r;"
+        },
+        {
+          "key": "proxy_temp_file_write_size",
+          "title": "proxy_temp_file_write_size",
+          "type": "string",
+          "required": false,
+          "description": "Default:\t\nproxy_temp_file_write_size 8k|16k;"
+        },
+        {
+          "key": "proxy_temp_path",
+          "title": "proxy_temp_path",
+          "type": "string",
+          "required": false,
+          "description": "Syntax: proxy_temp_path path [level1 [level2 [level3]]];eg. proxy_temp_path /spool/nginx/proxy_temp 1 2;"
+        }
+      ]
+    }
+  ]
+}

+ 29 - 0
src/pages/nginx/components/proxy/index.less

@@ -0,0 +1,29 @@
+
+
+.more-conf-popover{
+  .ant-popover-inner-content{
+    width: 100%;
+    padding: 5px;
+  }
+  .more-values.ant-input{
+    min-width: 650px;
+    border: none;
+    color: #333333;
+  }
+
+}
+
+.proxy-settings-input{
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  .less-values{
+    max-width: 250px;
+    overflow: hidden;
+    display: inline-block;
+    line-height: 32px;
+    height: 32px;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+}

+ 87 - 0
src/pages/nginx/components/proxy/index.tsx

@@ -0,0 +1,87 @@
+/**
+ * @author tuonian
+ * @date 2023/7/5
+ */
+import {Button, Drawer, Input, Popover, Tooltip} from "antd";
+import {AdvanceInputConfigs, AutoForm, AutoFormInstance, AutoTypeInputProps, isObject} from 'planning-tools'
+
+import './index.less'
+import {useEffect, useRef, useState} from "react";
+import {EditOutlined} from "@ant-design/icons";
+import {renderProxy} from "./utils.ts";
+import {useAppSelector} from "../../../../store";
+import FormConfig from './config.json'
+
+
+export const ProxySettings = ({value, onChange}: AutoTypeInputProps) => {
+
+  const nginx = useAppSelector(state => state.nginx.current)
+
+  const [data,setData] = useState<any>()
+  const [lines,setLines] = useState<string[]>([])
+  const [open,setOpen] = useState(false)
+
+  const formRef = useRef<AutoFormInstance>()
+
+  useEffect(()=>{
+    if (isObject(value)){
+      setData(value.data)
+      setLines(value.lines || [])
+    }
+    console.log('value change', value)
+  },[value])
+
+  const onSubmitData = async ()=>{
+    if (!nginx?.id){
+      return
+    }
+    const values = await formRef.current?.onSyncSubmit(true);
+    const lines = renderProxy(values, nginx)
+    const postData = {
+      lines: lines,
+      data:  values
+    }
+    onChange?.(postData)
+    setOpen(false)
+  }
+
+  const renderMoreContent = ()=>{
+    if (!lines?.length){
+      return <span>无配置,点击编辑按钮编辑代理设置</span>
+    }
+    return (<Input.TextArea className="more-values"
+                            rows={Math.min(10,lines.length)}
+                            disabled value={lines.join('\n')} />)
+  }
+
+  return (<div className="proxy-settings-input">
+    <Popover
+             overlayClassName="more-conf-popover"
+             destroyTooltipOnHide content={renderMoreContent}>
+      <span className="less-values">{lines.length ? lines.join(' ; ') : '无配置'}</span>
+    </Popover>
+    <Button onClick={()=>setOpen(true)} type="link" icon={<EditOutlined />} />
+    <Drawer title="代理设置"
+            open={open}
+            width={700}
+            onClose={()=>setOpen(false)}
+            rootClassName="proxy-drawer"
+            extra={<>
+              <Tooltip placement="rightBottom" title="提交后,请点击界面保存按钮,保存到服务器">
+                <Button onClick={onSubmitData} type="primary">提交</Button>
+              </Tooltip>
+
+               </>}
+            >
+
+      <AutoForm ref={formRef as never}
+                formProps={{
+                    labelCol:{span: 7}
+                }}
+                data={data}
+                columns={FormConfig.form} />
+    </Drawer>
+    </div>)
+}
+
+AdvanceInputConfigs["proxy_settings"] =ProxySettings

+ 70 - 0
src/pages/nginx/components/proxy/utils.ts

@@ -0,0 +1,70 @@
+import {cloneDeep, isBoolean} from "lodash";
+import {INginx, KeyValue} from "../../../../models/nginx.ts";
+import {isFalse, isNull} from "planning-tools";
+
+/**
+ * 渲染代理配置
+ * @param data
+ * @param nginx
+ */
+export const renderProxy = (data: any, nginx: INginx)=>{
+  const lines: string[] = []
+  const values = cloneDeep(data);
+  if (values.proxy_custom_config){
+    lines.push(values.proxy_custom_config)
+  }
+  delete values.proxy_custom_config
+
+  if (Array.isArray(values.proxy_set_header)){
+    values.proxy_set_header.forEach((item: KeyValue)=>{
+      if (isNull(item.value) || isNull(item.name)){
+        return
+      }
+      lines.push(`proxy_set_header    ${item.name}  ${item.value};`)
+    })
+  }
+  delete values.proxy_set_header
+
+  if (values.ssl_certificate){
+    values.proxy_ssl_certificate = `${nginx.dataDir}/certs/${values.ssl_certificate}.pem`
+    values.proxy_ssl_certificate_key = `${nginx.dataDir}/certs/${values.ssl_certificate}.key`
+    delete values.ssl_certificate
+  }
+
+  if (values.tmp_trans_ip){
+    lines.push(`proxy_set_header X-Real-IP $remote_addr;`)
+    lines.push(`proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`)
+  }
+  delete values.tmp_trans_ip
+  if (values.tmp_trans_host){
+    lines.push(`proxy_set_header Host $host;`)
+  }
+  delete values.tmp_trans_host
+  if (values.tmp_support_ws){
+    lines.push(`proxy_set_header Upgrade $http_upgrade;`)
+    lines.push(`proxy_set_header Connection "upgrade";`)
+  }
+  delete values.tmp_support_ws
+
+  delete values.tmp_proxy_more
+
+  if (isFalse(values.proxy_pass_request_body)){
+    lines.push(`proxy_pass_request_body   off;`)
+    lines.push(`proxy_set_header  Content-Length    "";`)
+  }
+  delete values.proxy_pass_request_body
+
+  Object.keys(values).forEach(k=>{
+    let v = values[k];
+    if (isNull(v)){
+      return
+    }
+    if (isBoolean(v)){
+      v = v ? 'on':'off'
+    }else if (Array.isArray(v)){
+      v = v.join(' ')
+    }
+    lines.push(`${k}  ${v};`)
+  })
+  return lines
+}

+ 31 - 0
src/pages/nginx/components/proxypass/index.less

@@ -0,0 +1,31 @@
+.proxy-pass-input{
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: flex-start;
+  .protocol.ant-select{
+    min-width: 80px;
+    width: 80px;
+    max-width: 80px;
+  }
+  .or{
+    margin-left: 5px;
+    margin-right: 5px;
+  }
+  .service-host-input{
+    min-width: 200px;
+    .ant-input{
+      min-width: 200px;
+    }
+  }
+  .upstream-select.ant-select{
+    min-width: 200px;
+    width: auto;
+  }
+}
+
+.proxy-pass-popover{
+  display: flex;
+  flex-direction: row;
+  min-width: 400px;
+}

+ 97 - 0
src/pages/nginx/components/proxypass/index.tsx

@@ -0,0 +1,97 @@
+/**
+ * 必须要兼容,手动输入,以及选择负载均衡名字
+ * @author tuonian
+ * @date 2023/7/4
+ */
+import {AutoTypeInputProps, AdvanceInputConfigs} from "planning-tools";
+import {Input, Select} from "antd";
+import {useAppSelector} from "../../../../store";
+import {ChangeEvent, useEffect, useMemo, useState} from "react";
+import './index.less'
+
+const protocols = [{value:"http", label:"http"},{ value: 'https',label: 'https'}]
+
+
+export const ProxyPassInput = ({value, onChange}: AutoTypeInputProps)=>{
+
+  const upstreamServer = useAppSelector(state => state.nginx.upstream);
+
+  const [data,setData] = useState<string>()
+  const [protocol,setProtocol] = useState<string>("http")
+
+  const options = useMemo(()=>{
+    let list:any[] = []
+    if (upstreamServer?.upstreams){
+      list =  upstreamServer.upstreams.map(item=>{
+        return {
+          label: item.name,
+          value: item.name
+        }
+      })
+    }
+    return list
+  },[upstreamServer])
+
+  useEffect(()=>{
+    if (!value || typeof value !=='string'){
+      setData(undefined)
+      return
+    }
+    if (value.startsWith('https://')){
+      setProtocol('https')
+    }else if (value.startsWith('http://')){
+      setProtocol('http')
+    }
+    let pass = value.replace(/http(s)?:\/\//,'');
+    if (pass.endsWith('/')){
+      pass = pass.substring(0,pass.length-1)
+    }
+    if (pass){
+      setData(pass)
+    }
+  },[value])
+
+  const triggerChange = (pro: string,host?: string)=>{
+    if (!pro || !host){
+      return
+    }
+    let pass = `${pro}://${host}`
+    if (!pass.endsWith('/')){
+      pass=pass+'/'
+    }
+    onChange?.(pass)
+  }
+
+  const onProtocolChange = (pro: string)=>{
+    setProtocol(pro)
+    triggerChange(pro,data)
+  }
+
+  const onSelectUpstream = (v?:string)=>{
+    if (v){
+      setData(v)
+      triggerChange(protocol,v)
+    }
+  }
+
+  const userInputChange = (e: ChangeEvent<any>) =>{
+    const v = e.currentTarget.value;
+    setData(v);
+    triggerChange(protocol,v)
+  }
+
+
+
+  return (<div className="proxy-pass-input">
+    <Select value={protocol} onChange={onProtocolChange}
+            className="protocol" options={protocols} />
+    <Input onChange={userInputChange} value={data} allowClear/>
+    <Select onChange={onSelectUpstream}
+            placeholder="选择负载均衡"
+            allowClear
+            className="upstream-select"
+            options={options} />
+    </div>)
+}
+
+AdvanceInputConfigs['proxy_pass'] = ProxyPassInput

+ 70 - 0
src/pages/nginx/components/proxypass/stream.tsx

@@ -0,0 +1,70 @@
+/**
+ * 必须要兼容,手动输入,以及选择负载均衡名字
+ * @author tuonian
+ * @date 2023/7/4
+ */
+import {AutoTypeInputProps, AdvanceInputConfigs} from "planning-tools";
+import {Button, Input, Popover, Select} from "antd";
+import {useAppSelector} from "../../../../store";
+import {ChangeEvent, useEffect, useMemo, useState} from "react";
+import {EditOutlined} from "@ant-design/icons";
+
+import './index.less'
+
+export const StreamProxyPassInput = ({value, onChange}: AutoTypeInputProps)=>{
+
+  const upstreamServer = useAppSelector(state => state.nginx.streamUpstream);
+  const [data,setData] = useState<string>()
+
+
+  const options = useMemo(()=>{
+    let list:any[] = []
+    if (upstreamServer?.upstreams){
+      list =  upstreamServer.upstreams.map(item=>{
+        return {
+          label: item.name,
+          value: item.name
+        }
+      })
+    }
+    return list
+  },[upstreamServer])
+
+  useEffect(()=>{
+   setData(value)
+  },[value])
+
+  const onSelectUpstream = (v?:string)=>{
+    if (v){
+      setData(v)
+      onChange?.(v)
+    }
+  }
+
+  const onUserInput = (e:ChangeEvent<any>)=>{
+    setData(e.currentTarget.value)
+    onChange?.(e.currentTarget.value)
+  }
+
+
+
+  const renderInput = ()=>{
+    return (<div className="proxy-pass-popover">
+      <Input placeholder="输入后端服务的IP:PORT或者复制均衡名称" value={data} onChange={onUserInput} />
+      <Select onChange={onSelectUpstream}
+              placeholder="选择负载均衡"
+              allowClear
+              className="upstream-select"
+              options={options} />
+    </div>)
+  }
+
+  return (<>
+    {data}
+    <Popover trigger="click" destroyTooltipOnHide content={renderInput}>
+      <Button type="link" icon={<EditOutlined />} />
+    </Popover>
+  </>)
+}
+
+AdvanceInputConfigs['stream_proxy_pass'] = StreamProxyPassInput

+ 336 - 0
src/pages/nginx/components/site/components/Dragger.tsx

@@ -0,0 +1,336 @@
+import React, {useCallback, useEffect, useRef, useState} from "react";
+
+import './dragger.less'
+import {uniqueKey} from "planning-tools";
+import {Button, Tree} from "antd";
+import {
+    InboxOutlined,
+    FileOutlined,
+    FolderOutlined,
+    DownOutlined,
+    DeleteOutlined, LoadingOutlined, UploadOutlined
+} from "@ant-design/icons";
+import type { DataNode } from 'antd/es/tree';
+import {uploadApis} from "../../../../../api/nginx.ts";
+import classNames from "classnames";
+
+/**
+ * 文件上传
+ */
+type MyFile = {
+
+    children?: MyFile[]
+    /**
+     * 是否为文件夹
+     */
+    isDirectory?: boolean
+    title: string
+    key: string
+    item: FileSystemEntry
+    pKey?: string
+
+} & DataNode
+
+type FileStatus = {
+    success?: boolean
+    error?: boolean
+    loading?: boolean
+    message?: string
+    key: string
+}
+
+type StatusMap = {
+    [key:string]: FileStatus
+}
+
+type TreeNodeProps = {
+    data: MyFile,
+    onDelete: (data: MyFile)=>void,
+    onUpload: (data: MyFile) =>void
+    statusMap: StatusMap
+}
+
+
+const TreeNode = ({data, onDelete, onUpload, statusMap}: TreeNodeProps)=>{
+    const [status,setStatus] = useState<FileStatus>({key: data.key})
+
+    useEffect(()=>{
+        const cur = statusMap[data.key];
+        cur && (setStatus(cur))
+    },[statusMap])
+
+    return (
+        <div className={classNames('node-data',{ 'error': status.error}, { success: status.success})}>
+            <span className="tree-node-title-item">{data.title}</span>
+            <div style={{flex: 1}} />
+            <LoadingOutlined hidden={!status.loading} />
+            <Button onClick={()=>onUpload(data)}
+                    size="small"
+                    type="link"
+                    hidden={status.success || data.isDirectory}
+                    icon={<UploadOutlined />}
+                    />
+            <Button onClick={()=>onDelete(data)}
+                    className="delete-btn"
+                    size="small" type="text"
+                    danger icon={<DeleteOutlined />} />
+        </div>
+    )
+}
+type IProps  = {
+    onComplete?: (key: string, ok?: boolean)=>void
+}
+export const Dragger:React.FC<IProps> = ({onComplete}: IProps) => {
+
+    const [ fileList,setFileList] = useState<MyFile[]>([])
+    const [loading,setLoading] = useState(false)
+    const [batchId,setBatchId] = useState<string>(uniqueKey(15))
+    const [statusMap,setStatusMap] = useState<StatusMap>({})
+    const filesRef = useRef<MyFile[]>([])
+
+    const dropRef = useRef<HTMLDivElement>()
+    const inputRef = useRef<HTMLInputElement>()
+    const fileMapRef = useRef<{[key:string]: MyFile}>({})
+
+
+    const onReset = ()=>{
+        setFileList([])
+        setBatchId(uniqueKey(15))
+        setStatusMap({})
+        fileMapRef.current = {}
+    }
+
+    useEffect(()=>{
+        if (!filesRef.current?.length){
+            return
+        }
+       const okList = filesRef.current.filter(item=>statusMap[item.key]?.success)
+        if (okList.length === filesRef.current.length){
+            onComplete?.(batchId, true)
+        }else {
+            onComplete?.(batchId, false)
+        }
+    },[statusMap])
+
+
+    const  onUploadFile = (data: MyFile)=>{
+        if (data.isDirectory){
+            return Promise.resolve()
+        }
+        const status = {
+            key: data.key,
+            message: '',
+            success: true,
+            error: false,
+            loading: true
+        }
+        setStatusMap(map=>({...map,[data.key]: status}))
+        return uploadApis.uploadFile(data.item as FileSystemFileEntry , batchId)
+            .then(({data: resp})=>{
+                const status = {
+                    key: data.key,
+                    message: '',
+                    success: true,
+                    error: false,
+                    loading: false
+                }
+                setStatusMap(map=>({...map,[data.key]: status}))
+                return resp
+            })
+            .catch(e=>{
+                const status = {
+                    key: data.key,
+                    message: e.message || '上传失败',
+                    success: false,
+                    error: true,
+                    loading: false
+                }
+                setStatusMap(map=>({...map,[data.key]: status}))
+            })
+    }
+    const onUpload = async ()=>{
+        setLoading(true)
+        const allList = Object.values(fileMapRef.current)
+            .filter(item=>!item.isDirectory)
+            .map(item=> ({...item}))
+        filesRef.current = [...allList]
+        const batchUpload = ()=>{
+            const tasks: Promise<any>[] = []
+            for (let i =0;i<4;i++){
+                const data = allList.pop()
+                if (!data){
+                    break
+                }
+                tasks.push(new Promise((resolve,reject) => {
+                    onUploadFile(data)
+                        .then(resp=>resolve(resp))
+                        .catch(e=>reject(e))
+                }))
+            }
+             Promise.all(tasks)
+                .then((data)=>{
+                    console.log('batch result', data)
+                })
+                 .catch(e=>{
+                     console.log('batch result fail', e)
+                 })
+                 .finally(()=>{
+                     if (allList.length){
+                         batchUpload()
+                     }else {
+                         setLoading(false)
+                         onComplete?.(batchId)
+                     }
+                 })
+        }
+
+        batchUpload()
+    }
+
+    const onDelete =(file: MyFile) => {
+        if (!file.pKey){
+            const list = fileList.filter(item=>item.key !== file.key);
+            setFileList(list)
+            return
+        }
+        const parent = fileMapRef.current[file.pKey];
+        if (!parent || !parent.children){
+            return;
+        }
+        parent.children = parent.children.filter(item=>item.key !== file.key)
+        if (!parent.children?.length){
+            const list = fileList.filter(item=>item.key !== parent.key);
+            setFileList(list);
+        }else {
+            setFileList([...fileList])
+        }
+        console.log('file',file, fileMapRef.current)
+    }
+    const appendFiles = (items: DataTransferItemList)=>{
+        const files: MyFile[] = []
+        const fileMap: {[key:string]:MyFile} = {}
+        const id = uniqueKey(15)
+        setBatchId(id)
+        let order = 1
+        const createKey = ()=>{
+            const key = id+'_'+order
+            order++
+            return key
+        }
+
+        const scanFiles = (item: FileSystemEntry, parent?: MyFile)=>{
+            let pList = parent ? parent.children : files;
+            if (!pList){
+                pList = []
+            }
+            if (parent){
+                parent.children = pList
+                fileMap[parent.key] = parent
+            }
+            if (item.isDirectory){
+                const myFile: MyFile = {
+                    title: item.name,
+                    key: createKey(),
+                    children: [],
+                    isDirectory: true,
+                    icon: <FolderOutlined />,
+                    item,
+                    isLeaf: false,
+                    pKey: parent?.key
+                }
+                pList.push(myFile)
+                const reader = (item as FileSystemDirectoryEntry).createReader()
+                reader.readEntries(function (entries){
+                    entries.forEach(entry=>{
+                        scanFiles(entry, myFile)
+                    })
+                })
+            }else{
+                const myFile = {
+                    title: item.name,
+                    key: createKey(),
+                    item,
+                    icon: <FileOutlined />,
+                    isLeaf: true,
+                    pKey: parent?.key,
+                }
+                pList.push(myFile)
+                fileMap[myFile.key] = myFile
+            }
+        }
+
+        for (let i=0;i<items.length;i++){
+            const item = items[i].webkitGetAsEntry();
+            if (!item){
+                continue
+            }
+            scanFiles(item)
+        }
+        setFileList(files)
+        fileMapRef.current = fileMap
+        console.log('files', files)
+    }
+
+    const onDragOver = useCallback((evt: DragEvent)=>{
+        evt.preventDefault()
+    },[])
+
+    const onDropEvent = useCallback((evt: DragEvent)=>{
+        evt.preventDefault()
+        if (!evt.dataTransfer){
+            console.log('onDropEvent dataTransfer is null')
+            return
+        }
+        const items = evt.dataTransfer.items;
+        const files = evt.dataTransfer.files;
+        appendFiles(items)
+        console.log('items', items)
+        console.log('files', files)
+    },[])
+
+    useEffect(()=>{
+        const container = dropRef.current;
+        if (!container){
+            return
+        }
+        container.addEventListener("dragover",onDragOver)
+        container.addEventListener("drop", onDropEvent)
+
+        return ()=>{
+            container.removeEventListener("dragover", onDragOver)
+            container.removeEventListener("drop", onDropEvent)
+        }
+
+    },[])
+
+    return (<div className="dragger-input">
+        <div onClick={()=>inputRef.current?.click()} ref={dropRef as any} className="dragger">
+            <p className="ant-upload-drag-icon">
+                <InboxOutlined />
+            </p>
+            <p className="ant-upload-text">点击或者拖拽文件添加上传的问题</p>
+            <p className="ant-upload-hint">
+               支持单个文件,或者多个文件,将会压缩成tar.xz文件上传到服务端;更新不会备份Nginx服务器上已有的文件,如果初次使用该功能,请注意文件备份,更新的文件在服务端和nginx的服务器均有备份
+            </p>
+        </div>
+        <input hidden ref={inputRef as any} type="file"/>
+        <div className="file-tree-title btn-list">
+            <span className="list-name">文件列表</span>
+            <Button danger type="link" size="small" disabled={loading} hidden={!fileList.length} onClick={onReset}>清空</Button>
+            <Button type="link" size="small" loading={loading} hidden={!fileList.length} onClick={onUpload} >上传</Button>
+        </div>
+        <Tree
+            showIcon
+            showLine
+            blockNode
+            switcherIcon={<DownOutlined />}
+            autoExpandParent
+            defaultExpandAll
+            titleRender={(data)=>(<TreeNode onUpload={onUploadFile}
+                                            statusMap={statusMap}
+                                            data={data}
+                                            onDelete={onDelete} />)}
+            treeData={fileList} />
+
+    </div>)
+}

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů