uric组件2022文档

Uric组件2022文档

预备知识点:

Python基础
Mysql
前端
Django
DRF组件+VUE3

一 前端项目初始化

1.1 客户端项目创建

我们使用的vue-cli脚手架作为我们前端开发使用的框架,下面看一下vue-cli的安装。

安装脚手架:

$ npm install -g @vue/cli
# OR
$ yarn global add @vue/cli

项目前端环境版本依赖

image-20220525上午115212287

node的版本:v14.16.0以上
vue-cli需要的安装和运行需要借助到node.js的环境(换句话说也是js代码的解释器)
vue:3版本
@vue/cli 4.5.13

1.1.1 Node.js的安装

Node.js是一个服务端语言,它的语法和JavaScript类似,所以可以说它是属于前端的后端语言,后端语言和前端语言的区别:

node.js的版本有两大分支:

官方发布的node.js版本:0.xx.xx 这种版本号就是官方发布的版本
社区发布的node.js版本:xx.xx.x  就是社区开发的版本

Node.js如果安装成功,可以查看Node.js的版本,在终端输入如下命令:

node -v
npm -v   #pip

在安装node.js完成后,在node.js中会同时帮我们安装一个包管理器npm。我们可以借助npm命令来安装node.js的第三方包。这个工具相当于python的pip管理器,php的composer,go语言的go get,java的maven。

1.1.2 npm

常用指令

npm install -g 包名              # 安装模块   -g表示全局安装,如果没有-g,则表示在当前项目跟母下.node_modules下保存
npm list                        # 查看当前目录下已安装的node包
npm view 包名 engines            # 查看包所依赖的Node的版本 
npm outdated                    # 检查包是否已经过时,命令会列出所有已过时的包
npm update 包名                  # 更新node包
npm uninstall 包名               # 卸载node包
npm 命令 -h                      # 查看指定命令的帮助文档

如果npm大家觉得速度比较慢,可以安装cnpm来进行国内包源的下载

cnpm介绍

  1. 说明:因为谷歌安装插件是从国外服务器下载,受网络影响大,可能出现异常,如果谷歌的服务器在中国就好了,所以我们乐于分享的淘宝团队干了这事来自官网:“这是一个完整npmjs.org镜像,你可以用此代替官方版本(只读),同步频率目前为10分钟一次以保证尽量与官方服务同步“。
  2. 官方网址:http://npm.taobao.org
  3. 安装:命令提示符执行npm install cnpm -g --registry=https://registry.npm.taobao.org
  4. 注意:安装完后最好查看其版本cnpm -v或关闭命令提示符重新打开,安装完直接使用有可能会出现错误
//临时使用
npm install jquery --registry https://registry.npm.taobao.org

//可以把这个选型配置到文件中,这样不用每一次都很麻烦
npm config set registry https://registry.npm.taobao.org

//验证是否配置成功 
npm config list 或者 npm config get registry

//安装cnpm,在任意目录下都可执行,--global是全局安装,不可省略
npm install --global cnpm 或者 npm install -g cnpm --registry=https://registry.npm.taobao.org

//安装后直接使用
cnpm install jquery

说明:NPM(节点包管理器)是的NodeJS的包管理器,用于节点插件管理(包括安装,卸载,管理依赖等)

  1. 使用NPM安装插件:命令提示符执行npm install <name> [-g] [--save-dev]
    <name>:节点插件名称。
    例:npm install gulp-less --save-dev
  2. -g:全局安装。 将会安装在C:\ Users \ Administrator \ AppData \ Roaming \ npm,并且写入系统环境变量;非全局安装:将会安装在当前定位目录;全局安装可以通过命令行任何地方调用它,本地安装将安装在定位目录的node_modules文件夹下,通过要求()调用;
  3. --save:将保存至的package.json(的package.json是的NodeJS项目配置文件)
  4. -dev:保存至的package.json的devDependencies节点,不指定-dev将保存至依赖节点
这个错误通常是由于在尝试访问某个服务器时,系统发现该服务器的 SSL 证书已过期,导致无法建立安全连接。要解决这个问题,可以尝试以下方法:

### 方法 1: 使用 `npm` 忽略 SSL 证书问题
您可以配置 `npm` 忽略 SSL 证书的错误,但这只是一个临时解决方案,并不推荐长期使用,因为它降低了连接的安全性。

```bash
npm config set strict-ssl false

然后再次尝试安装:

npm install cnpm -g --registry=https://registry.npm.taobao.org
注意未生效就手动保存下环境变量

方法 2: 更新 npm

有时,更新 npm 到最新版本可能会解决证书问题:

npm install -g npm

方法 3: 使用不同的镜像源

如果 npm.taobao.org 出现问题,可以尝试使用其他镜像源,例如:

npm install cnpm -g --registry=https://registry.npmmirror.com

方法 4: 更新系统的根证书

如果您的系统根证书已经过期,可以尝试更新它们。具体步骤取决于您的操作系统,但一般可以通过以下方法更新:

  • Windows: 在控制面板中,搜索并打开 "Internet选项",在“内容”选项卡下点击“证书”,然后查看和更新根证书。
  • macOS 和 Linux: 更新系统的软件包来刷新根证书。

方法 5: 手动安装

如果以上方法都失败,您可以尝试手动安装 cnpm,从 GitHub 上下载源代码并自行编译。

方法 6: 使用 nvm 切换 Node.js 版本

有时问题可能与特定版本的 Node.js 有关,可以使用 nvm(Node Version Manager)来切换到其他版本的 Node.js 尝试解决问题。

日志文件查看

如果问题依然存在,建议查看 C:\Users\Administrator\AppData\Local\npm-cache\_logs\2024-09-01T06_26_24_828Z-debug-0.log 日志文件,寻找更详细的错误信息。

希望这些方法可以帮助您解决问题!



为什么要保存至的的package.json?因为节点插件包相对来说非常庞大,所以不加入版本管理,将配置信息写入的package.json并将其加入版本管理,其他开发者对应下载即可(命令提示符执行npm install,则会根据package.json下载所有需要的包)

### 1.1.3  vue-cli创建项目

#### 创建项目

	使用vue自动化工具可以快速搭建单页应用项目目录。
	
	该工具为现代化的前端开发工作流提供了开箱即用的构建配置。只需几分钟即可创建并启动一个带热重载、保存时静态检查以及可用于生产环境的构建配置的项目:

```js
vue create uric_web

// 启动开发服务器 ctrl+c 停止服务
cd uric_web
npm run serve           // 运行这个命令就可以启动node提供的测试http服务器

image-20220525上午115358990

image-20220525上午115430157

image-20220525上午115523518

image-20220525上午115622757

// 那么访问一下命令执行完成后提示出来的网址就可以看到网站了:http://localhost:8080/

image-20220525上午115725344

项目创建完成之后,我们会看到urilsweb项目其实是一个文件夹,我们进入到文件夹内部就会发现一些目录和文件,我们简单介绍一下它们都是干什么的

项目目录结构介绍

核心文件和目录

src/         主开发目录,要开发的客户端代码文件(单文件组件,样式、工具函数等等)全部在这个目录下

static/      静态资源目录,项目中的静态资源(css,js,图片等文件)放在这个文件夹

dist/        项目打包发布文件夹,目前没有这个文件夹,最后要上线单文件项目文件都在这个文件夹中	
		    后面使用npm build 打包项目,让项目中的vue组件经过编译变成js 代码以后,dist就出现了

node_modules/     node的包目录,项目运行的依赖包存储目录,
                  package.json和package-lock.json文件中会自动记录了这个目录下所有的包以及包的版本信息,
                  如果node_modules没有,但是有package.json,则可以在终端下,通过npm install进行恢复。

config/      配置目录,是环境配置目录与项目无关。

build/       项目打包时依赖的目录

src/router/  路由,是我们创建项目的时候,如果选择安装vue-router,就自动会生成这个目录。
src/assets/  静态资源存储目录,与static目录作用类似。
src/components/  组件存储目录,就是浏览器中用户看到的页面的一部分内容。
src/views/       组件存储目录,就是浏览器中用户看到的页面内容,views往往会加载并包含components中的组件进来

目录结构详细介绍

image-20210907100137864

项目执行流程图

1625305034687

1.1.4 展示中心组件

src/main.js

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

createApp(App).use(router).mount('#app')

src/App.vue

<template>
  <router-view/>
</template>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

#nav {
  padding: 30px;
}

#nav a {
  font-weight: bold;
  color: #2c3e50;
}

#nav a.router-link-exact-active {
  color: #42b983;
}
</style>

router/index.js,代码:

import { createRouter, createWebHistory } from 'vue-router'
import ShowCenter from '../views/ShowCenter.vue'

const routes = [
  {
    path: '/',
    name: 'ShowCenter',
    component: ShowCenter
  }
]

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
})

export default router

views/ShowCenter.vue

<template>
  <div class="showcenter">
    <h1>show center</h1>
  </div>
</template>

<script>

export default {
  name: 'ShowCenter',
}
</script>

访问http://localhost:8080 就看到了我们的展示中心页面。

1.1.5 调整配置

为了方便开发,我们做一些配置调整

Vue.config.js是一个可选的配置文件,如果项目的根目录存在这个文件,那么它就会被 @vue/cli-service 自动加载。你也可以使用package.json中的vue字段,但要注意严格遵守JSON的格式来写。这里使用配置vue.config.js的方式进行处理。

const {defineConfig} = require('@vue/cli-service')

module.exports = defineConfig({
    transpileDependencies: true,
    devServer: {
        host: "localhost",
        /* 本地ip地址 */
        //host: "192.168.0.131",
        // host: "www.uric.cn", //局域网和本地访问
        port: "8000",
        // hot: true,
        /* 自动打开浏览器 */
        // open: false,
        /*overlay: {
            warning: false,
            error: true
        },*/
        /* 跨域代理 */
        /*proxy: {
            "/api": {
                /!* 目标代理服务器地址 *!/
                target: "http://xxxx.top", //
                /!* 允许跨域 *!/
                changeOrigin: true,
                ws: true,
                pathRewrite: {
                    "^/api": ""
                }
            }
        }*/
    },
})

我们现在为前端和后端分别设置两个不同的域名:

位置 域名
前端 www.uric.cn
后端 api.uric.cn

Linux/mac系统下执行指令

vi /etc/hosts

# windows下是C:/windows/system32/drivers/etc/hosts

加上如下内容

127.0.0.1   localhost
127.0.0.1   api.uric.cn
127.0.0.1   www.uric.cn

部分使用windows开发的同学,如果hosts修改保存不了,可以复制这个文件到桌面,修改完成以后,在粘贴到对应目录下。

image-20220625103718596

在开发过程中,我们可能经常会在前端项目的业务里面使用到某些变量,我们可以添加到配置文件中,比如我们在src目录下创建一个settings.js文件

export default { // 注意,对象要抛出后,其他文件中才能引入使用
    host: 'http://api.urils.cn:8000' // 我们的后台项目将来就通过这个域名和端口来启动
}

为了方便后面其他页面组件使用settings中的配置变量,我们在main.js文件中引入封装成vue对象的全局属性.

main.js,代码:

import {createApp} from 'vue'
import App from './App.vue'
import router from './router'
import settings from "@/settings";

const app = createApp(App)
app.use(router).mount('#app')
app.config.globalProperties.$settings = settings;

1.1.6 安装axios

后面我们需要在前端来获取后端的数据,意味着要发送请求,我们使用axios模块来进行http请求的发送,

它的特点和ajax一样:异步请求。

项目根目录下执行如下指令
npm install -S axios --registry https://registry.npm.taobao.org

ShowCenter组件:

<template>
  <div class="showcenter">
    <h1>show center</h1>
  </div>
</template>

<script>
import axios from 'axios'

export default {
  name: 'ShowCenter',
  mounted() {
    console.log(this.$settings.host)
    axios.get('http://wthrcdn.etouch.cn/weather_mini?city=北京')
        .then((response) => {
          console.log("response:::", response.data.data.forecast)
        })
  }
}
</script>

image-20220525下午15735710

接下来我们将展示中心页面写的好看一些。

我们当前前端项目使用的用于展示界面的前端插件是Ant Design,能够帮我们快速优雅的完成前端页面效果,下面介绍一下。

1.2 ant-design插件

介绍

Ant Design 是一个致力于提升『用户』和『设计者』使用体验的中台设计语言。它模糊了产品经理、交互设计师、视觉设计师、前端工程师、开发工程师等角色边界,将进行 UE 设计和 UI 设计人员统称为『设计者』,利用统一的规范进行设计赋能,全面提高中台产品体验和研发效率,是蚂蚁金服出品的开源框架。

Ant Design 官方介绍: "在中台产品的研发过程中,会出现不同的设计规范和实现方式,但其中往往存在很多类似的页面和组件,给设计师和工程师带来很多困扰和重复建设,大大降低了产品的研发效率。"

蚂蚁金服体验技术部经过大量的项目实践和总结,沉淀出设计语言 Ant Design,这可不单纯只是设计原则、控件规范和视觉尺寸,还配套有前端代码实现方案。也就是说采用Ant Design后,UI设计和前端界面研发可同步完成,效率大大提升。目前有阿里、美团、滴滴、简书采用。Ant Design有Web版和Moblie版。

如果前端这些插件都是我们通过js或者jquery手撸的话,工作量太重不说,效率还低。

Ant Design 则封装了一系列高质量的 React 组件,十分适用于在企业级的应用中,框架提供的 api 十分详尽,上手和使用相对简单,值得一提的是, Ant Design 使用 ES6 进行编写,因此使用过程中对 ES6 也是一次学习的机会。

我们现在学习的是vue框架和ant-desigin的兼容,那么已经有高手开源出了一套ant-design的vue实现,下面我们就来学习使用。

ant-desigin特点

  • 专为Web应用程序设计的企业级UI。
  • 开箱即用的一组高质量React组件。
  • 用具有可预测的静态类型的TypeScript编写。
  • 整套设计资源和开发工具。
  • 支持数十种语言的国际化。
  • 强大的主题自定义细节。

常用网址

官网:https://ant.design/,antdv工具的使用,全称 Ant Design of Vue。

官网地址:https://next.antdv.com/docs/vue/getting-started-cn

Ant Design of Vue的使用,我们在项目中学习。

安装上手

$ npm i --save ant-design-vue@next

注意:package.json中的"ant-design-vue": "^3.2.7",一定是3以上版本

在main.js文件中引入

import {createApp} from 'vue'
import App from './App.vue'
import router from './router'
import Antd from 'ant-design-vue';
import 'ant-design-vue/dist/antd.css';
import './settings'
import settings from "@/settings";


const app = createApp(App)
app.use(router).use(Antd).mount('#app')

app.config.globalProperties.$settings = settings;

下面测试一下效果,我们在ShowCenter.vue组件中引入一个ant-design的button按钮,看看怎么样

<template>
  <div class="showcenter">
    <h1>show center</h1>
    <a-button type="primary">Primary</a-button>
  </div>
</template>

<script>
import axios from 'axios'

export default {
  name: 'ShowCenter',
  mounted() {
    console.log(this.$settings.host)
    axios.get('http://wthrcdn.etouch.cn/weather_mini?city=北京')
        .then((response) => {
          console.log("response:::", response.data.data.forecast)
        })
  }
}
</script>

好,效果有了。

image-20220525下午20217438

中文支持

ShowCenter.vue展示中文日历,没有配置之前:

<template>
  <div class="showcenter">
    <h1>show center</h1>
    <a-button type="primary">Primary</a-button>
    <div :style="{ width: '300px', border: '1px solid #d9d9d9', borderRadius: '4px' }">
      <a-calendar v-model:value="value" :fullscreen="false" @panelChange="onPanelChange"/>
    </div>
  </div>
</template>

<script>
import axios from 'axios'
import {ref} from 'vue';

export default {
  name: 'ShowCenter',
  setup() {
    const value = ref();

    const onPanelChange = (value, mode) => {
      console.log(value, mode);
    };

    return {
      value,
      onPanelChange,
    };
  },
  mounted() {
    console.log(this.$settings.host)
    axios.get('http://wthrcdn.etouch.cn/weather_mini?city=北京')
        .then((response) => {
          console.log("response:::", response.data.data.forecast)
        })
  }
}
</script>

ant-design-vue 目前的默认文案是英文。在使用某些插件(比如时间日期选择框等)的时候,需要我们来做中文支持,设置如下。

src/App.vue,代码:

<template>
  <a-config-provider :locale="locale">
    <div class="showcenter">
      <h1>show center</h1>
      <a-button type="primary">Primary</a-button>
      <div :style="{ width: '300px', border: '1px solid #d9d9d9', borderRadius: '4px' }">
        <a-calendar v-model:value="value" :fullscreen="false" @panelChange="onPanelChange"/>
      </div>
    </div>
  </a-config-provider>
</template>

<script>
import axios from 'axios'
import {ref} from 'vue';

import zhCN from 'ant-design-vue/es/locale/zh_CN';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';

dayjs.locale('zh-cn');

export default {
  name: 'ShowCenter',
  data() {
    return {
      locale: zhCN,
    };
  },
  setup() {
    const value = ref();

    const onPanelChange = (value, mode) => {
      console.log(value, mode);
    };

    return {
      value,
      onPanelChange,
    };
  },
  mounted() {
    console.log(this.$settings.host)
    axios.get('http://wthrcdn.etouch.cn/weather_mini?city=北京')
        .then((response) => {
          console.log("response:::", response.data.data.forecast)
        })
  }
}
</script>

需要安装一个包:

npm install dayjs

echarts图表插件

由于我们有很多的图表数据展示,而Ant-desigin中没有很优秀的图表插件,所以我们还需要借助其他开源插件,比如echarts和hcharts,我们本次采用的是百度开源的echarts,那么我们如何引入使用呢,看下面的步骤。

Echarts官方

下载:

npm install echarts --save --registry=https://registry.npm.taobao.org

为了方便后面组件的使用,我们在src/main.js中引入一下

// import echarts from 'echarts'
let echarts = require('echarts')
app.config.globalProperties.$echarts = echarts

在ShowCenter.vue组件中简单使用

<template>
  <h1>show center</h1>
  <p>
    <a-button type="primary">Primary</a-button>
  </p>
  <a-config-provider :locale="locale">
    <div class="showcenter">
      <div class="calendar" :style="{ width: '400px', border: '1px solid #d9d9d9', borderRadius: '4px'}">
        <a-calendar v-model:value="value" :fullscreen="false" @panelChange="onPanelChange"/>
      </div>
      <div class="chart" ref="chart"></div>
    </div>
  </a-config-provider>
</template>

<script>
import axios from 'axios'
import {ref} from 'vue';

import zhCN from 'ant-design-vue/es/locale/zh_CN';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';

dayjs.locale('zh-cn');

export default {
  name: 'ShowCenter',
  data() {
    return {
      locale: zhCN,
    };
  },
  methods: {
    init_chart() {
      // 基于准备好的dom,初始化echarts实例
      let myChart = this.$echarts.init(this.$refs.chart)
      // 绘制图表
      let option = {
        tooltip: {
          trigger: 'axis',
          axisPointer: {
            // Use axis to trigger tooltip
            type: 'shadow' // 'shadow' as default; can also be 'line' or 'shadow'
          }
        },
        legend: {},
        grid: {
          left: '3%',
          right: '4%',
          bottom: '3%',
          containLabel: true
        },
        xAxis: {
          type: 'value'
        },
        yAxis: {
          type: 'category',
          data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
        },
        series: [
          {
            name: 'Direct',
            type: 'bar',
            stack: 'total',
            label: {
              show: true
            },
            emphasis: {
              focus: 'series'
            },
            data: [320, 302, 301, 334, 390, 330, 320]
          },
          {
            name: 'Mail Ad',
            type: 'bar',
            stack: 'total',
            label: {
              show: true
            },
            emphasis: {
              focus: 'series'
            },
            data: [120, 132, 101, 134, 90, 230, 210]
          },
          {
            name: 'Affiliate Ad',
            type: 'bar',
            stack: 'total',
            label: {
              show: true
            },
            emphasis: {
              focus: 'series'
            },
            data: [220, 182, 191, 234, 290, 330, 310]
          },
          {
            name: 'Video Ad',
            type: 'bar',
            stack: 'total',
            label: {
              show: true
            },
            emphasis: {
              focus: 'series'
            },
            data: [150, 212, 201, 154, 190, 330, 410]
          },
          {
            name: 'Search Engine',
            type: 'bar',
            stack: 'total',
            label: {
              show: true
            },
            emphasis: {
              focus: 'series'
            },
            data: [820, 832, 901, 934, 1290, 1330, 1320]
          }
        ]
      };
      myChart.setOption(option);

    },
  },
  setup() {
    const value = ref();

    const onPanelChange = (value, mode) => {
      console.log(value, mode);
    };

    return {
      value,
      onPanelChange,
    };
  },
  mounted() {

    console.log(this.$settings.host)
    axios.get('http://wthrcdn.etouch.cn/weather_mini?city=北京')
        .then((response) => {
          console.log("response:::", response.data.data.forecast)
        })

    this.init_chart();
  }
}
</script>

<style>
.calendar, .chart {
  width: 500px;
  height: 500px;
  float: left;
  margin: 0 auto 0 100px;
}


</style>

image-20220525下午21612483

改进组合API:

<template>
  <h1>show center</h1>
  <p>
    <a-button type="primary">Primary</a-button>
  </p>
  <div class="chart" ref="chart"></div>
  <div class="chart2" ref="chart2"></div>

</template>

<script setup>


import {onMounted, ref} from "vue";
import zhCN from 'ant-design-vue/es/locale/zh_CN';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import * as echarts from 'echarts';

dayjs.locale('zh-cn');
const locale = zhCN

// (1)
const value = ref();
const onPanelChange = (value, mode) => {
  console.log(value, mode);
};


// (2)
let init_chart = () => {

  var myChart = echarts.init(chart.value);
  var option;
  // 绘制图表
  option = {
    tooltip: {
      trigger: 'axis',
      axisPointer: {
        // Use axis to trigger tooltip
        type: 'shadow' // 'shadow' as default; can also be 'line' or 'shadow'
      }
    },
    legend: {},
    grid: {
      left: '3%',
      right: '4%',
      bottom: '3%',
      containLabel: true
    },
    xAxis: {
      type: 'value'
    },
    yAxis: {
      type: 'category',
      data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
    },
    series: [
      {
        name: 'Direct',
        type: 'bar',
        stack: 'total',
        label: {
          show: true
        },
        emphasis: {
          focus: 'series'
        },
        data: [320, 302, 301, 334, 390, 330, 320]
      },
      {
        name: 'Mail Ad',
        type: 'bar',
        stack: 'total',
        label: {
          show: true
        },
        emphasis: {
          focus: 'series'
        },
        data: [120, 132, 101, 134, 90, 230, 210]
      },
      {
        name: 'Affiliate Ad',
        type: 'bar',
        stack: 'total',
        label: {
          show: true
        },
        emphasis: {
          focus: 'series'
        },
        data: [220, 182, 191, 234, 290, 330, 310]
      },
      {
        name: 'Video Ad',
        type: 'bar',
        stack: 'total',
        label: {
          show: true
        },
        emphasis: {
          focus: 'series'
        },
        data: [150, 212, 201, 154, 190, 330, 410]
      },
      {
        name: 'Search Engine',
        type: 'bar',
        stack: 'total',
        label: {
          show: true
        },
        emphasis: {
          focus: 'series'
        },
        data: [820, 832, 901, 934, 1290, 1330, 1320]
      }
    ]
  };
  option && myChart.setOption(option);
}

const chart = ref();

onMounted(() => {
  init_chart()
  init_chart2()
});

// (3)

const chart2 = ref();

let init_chart2 = () => {
  console.log(chart2.value)
  var myChart = echarts.init(chart2.value);
  var option;
  option = {
    tooltip: {
      trigger: 'item'
    },
    legend: {
      top: '5%',
      left: 'center'
    },
    series: [
      {
        name: 'Access From',
        type: 'pie',
        radius: ['40%', '70%'],
        avoidLabelOverlap: false,
        itemStyle: {
          borderRadius: 10,
          borderColor: '#fff',
          borderWidth: 2
        },
        label: {
          show: false,
          position: 'center'
        },
        emphasis: {
          label: {
            show: true,
            fontSize: '40',
            fontWeight: 'bold'
          }
        },
        labelLine: {
          show: false
        },
        data: [
          {value: 1048, name: 'Search Engine'},
          {value: 735, name: 'Direct'},
          {value: 580, name: 'Email'},
          {value: 484, name: 'Union Ads'},
          {value: 300, name: 'Video Ads'}
        ]
      }
    ]
  };

  option && myChart.setOption(option);

}
</script>

<style>
.chart, .chart2 {
  width: 500px;
  height: 500px;
  float: left;
  margin: 0 auto 0 100px;
}


</style>

![截屏2022-06-21 20.31.34](/Volumes/fumi_存储空间1/小心心备份/同步小心心文件夹/lu/luffy课件/第8模块课件/运维自动化平台/day06/yuan-实战项目文档/assets/截屏2022-06-21 20.31.34.png)

好,到此前端项目初始化完成。

1.3 组件初始化

登录组件初始化

views/Login.vue,代码

<template>
  <div class="login box">
    <img src="../assets/login.jpg" alt="">
    <div class="login">
      <div class="login-title">
        <p class="hi">Hello,Urils!</p>
      </div>
      <div class="login_box">
        <div class="title">
          <span>登录</span>
        </div>
        <div class="inp">
          <a-input v-model:value="username" type="text" placeholder="用户名" class="user"></a-input>
          <a-input v-model:value="password" type="password" class="pwd" placeholder="密码"></a-input>
          <div class="rember">
            <p>
              <input type="checkbox" class="no" v-model="remember"/>
              <span>记住密码</span>
            </p>
          </div>
          <button class="login_btn" @click="login">登录</button>

        </div>

      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'Login',
  data() {
    return {
      username: '',
      password: '',
      remember: true
    }
  },

  methods: {
    login() {
    
    }

  }

}
</script>

<style scoped>
.login .hi{
  font-size: 20px;
  font-family: "Times New Roman";
  font-style: italic;
}
.box {
  width: 100%;
  height: 100%;
  position: relative;
  overflow: hidden;
}

.box img {
  width: 100%;
  min-height: 100%;
}

.box .login {
  position: absolute;
  width: 500px;
  height: 400px;
  left: 0;
  margin: auto;
  right: 0;
  bottom: 0;
  top: -338px;
}

.login .login-title {
  width: 100%;
  text-align: center;
}

.login-title img {
  width: 190px;
  height: auto;
}

.login-title p {
  font-size: 18px;
  color: #fff;
  letter-spacing: .29px;
  padding-top: 10px;
  padding-bottom: 50px;
}

.login_box {
  width: 400px;
  height: auto;
  background: rgba(255, 255, 255, 0.3);
  box-shadow: 0 2px 4px 0 rgba(0, 0, 0, .5);
  border-radius: 4px;
  margin: 0 auto;
  padding-bottom: 40px;
}

.login_box .title {
  font-size: 20px;
  color: #9b9b9b;
  letter-spacing: .32px;
  border-bottom: 1px solid #e6e6e6;
  display: flex;
  justify-content: space-around;
  padding: 50px 60px 0 60px;
  margin-bottom: 20px;
  cursor: pointer;
}

.login_box .title span:nth-of-type(1) {
  color: #4a4a4a;
  border-bottom: 2px solid #396fcc;
}

.inp {
  width: 350px;
  margin: 0 auto;
}

.inp input {
  outline: 0;
  width: 100%;
  height: 45px;
  border-radius: 4px;
  border: 1px solid #d9d9d9;
  text-indent: 20px;
  font-size: 14px;
  background: #fff !important;
}

.inp input.user {
  margin-bottom: 16px;
}

.inp .rember {
  display: flex;
  justify-content: space-between;
  align-items: center;
  position: relative;
  margin-top: 10px;
}

.inp .rember p:first-of-type {
  font-size: 12px;
  color: #4a4a4a;
  letter-spacing: .19px;
  margin-left: 22px;
  display: -ms-flexbox;
  display: flex;
  -ms-flex-align: center;
  align-items: center;
  /*position: relative;*/
}

.inp .rember p:nth-of-type(2) {
  font-size: 14px;
  color: #9b9b9b;
  letter-spacing: .19px;
  cursor: pointer;
}

.inp .rember input {
  outline: 0;
  width: 30px;
  height: 45px;
  border-radius: 4px;
  border: 1px solid #d9d9d9;
  text-indent: 20px;
  font-size: 14px;
  background: #fff !important;
}

.inp .rember p span {
  display: inline-block;
  font-size: 12px;
  width: 100px;
  /*position: absolute;*/
  /*left: 20px;*/

}

#geetest {
  margin-top: 20px;
}

.login_btn {
  width: 100%;
  height: 45px;
  background: #396fcc;
  border-radius: 5px;
  font-size: 16px;
  color: #fff;
  letter-spacing: .26px;
  margin-top: 30px;
}

.inp .go_login {
  text-align: center;
  font-size: 14px;
  color: #9b9b9b;
  letter-spacing: .26px;
  padding-top: 20px;
}

.inp .go_login span {
  color: #84cc39;
  cursor: pointer;
}
</style>

src/router/index.js,代码:

import { createRouter, createWebHistory } from 'vue-router'
import ShowCenter from '../views/ShowCenter.vue'
import Login from '../views/Login.vue'

const routes = [
  {
    path: '/',
    name: 'ShowCenter',
    component: ShowCenter
  },
  {
    path: '/login',
    name: 'Login',
    component: Login
  }
]

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
})

export default router

image-20220525下午61821365

由于除了登录页面之外我们后面所有的组件都具备顶部导航栏和左侧菜单栏的效果,所以我直接将共有效果放到了一个Base.vue组件中。里面通过 ant design vue中的

布局组件:https://next.antdv.com/components/layout-cn

Base组件初始化

布局和导航菜单搭建App页面效果,简单如下

views/Base.vue,代码:

<template>
  <a-layout style="min-height: 100vh">
    <a-layout-sider v-model:collapsed="collapsed" collapsible>
      <div class="logo" />
      <a-menu v-model:selectedKeys="selectedKeys" theme="dark" mode="inline">
        <a-menu-item key="1">
          <pie-chart-outlined />
          <span>Option 1</span>
        </a-menu-item>
        <a-menu-item key="2">
          <desktop-outlined />
          <span>Option 2</span>
        </a-menu-item>
        <a-sub-menu key="sub1">
          <template #title>
            <span>
              <user-outlined />
              <span>User</span>
            </span>
          </template>
          <a-menu-item key="3">Tom</a-menu-item>
          <a-menu-item key="4">Bill</a-menu-item>
          <a-menu-item key="5">Alex</a-menu-item>
        </a-sub-menu>
        <a-sub-menu key="sub2">
          <template #title>
            <span>
              <team-outlined />
              <span>Team</span>
            </span>
          </template>
          <a-menu-item key="6">Team 1</a-menu-item>
          <a-menu-item key="8">Team 2</a-menu-item>
        </a-sub-menu>
        <a-menu-item key="9">
          <file-outlined />
          <span>File</span>
        </a-menu-item>
      </a-menu>
    </a-layout-sider>
    <a-layout>
      <a-layout-header style="background: #fff; padding: 0" />
      <a-layout-content style="margin: 0 16px">
        <a-breadcrumb style="margin: 16px 0">
          <a-breadcrumb-item>User</a-breadcrumb-item>
          <a-breadcrumb-item>Bill</a-breadcrumb-item>
        </a-breadcrumb>
        <div :style="{ padding: '24px', background: '#fff', minHeight: '360px' }">
          Bill is a cat.
        </div>
      </a-layout-content>
      <a-layout-footer style="text-align: center">
        Ant Design ©2018 Created by Ant UED
      </a-layout-footer>
    </a-layout>
  </a-layout>
</template>
<script>
import { PieChartOutlined, DesktopOutlined, UserOutlined, TeamOutlined, FileOutlined } from '@ant-design/icons-vue';
import { defineComponent, ref } from 'vue';
export default defineComponent({
  components: {
    PieChartOutlined,
    DesktopOutlined,
    UserOutlined,
    TeamOutlined,
    FileOutlined,
  },

  data() {
    return {
      collapsed: ref(false),
      selectedKeys: ref(['1']),
    };
  },

});
</script>
<style>
#components-layout-demo-side .logo {
  height: 32px;
  margin: 16px;
  background: rgba(255, 255, 255, 0.3);
}

.site-layout .site-layout-background {
  background: #fff;
}
[data-theme='dark'] .site-layout .site-layout-background {
  background: #141414;
}
</style>

设置路由:

 {
        path: '/base',
        name: 'Base',
        component: Base
    },

image-20220525下午62557493

Base组件修改

Base.vue修改菜单中的标题信息,Base.vue,代码:

<template>
  <a-layout style="min-height: 100vh">
    <a-layout-sider v-model:collapsed="collapsed" collapsible>
      <div class="logo"
           style="font-style: italic;text-align: center;font-size: 20px;color:#fff;margin: 10px 0;background-color: #333;line-height: 50px;font-family: 'Times New Roman'">
        <span> Urils</span>
      </div>
      <div class="logo"/>
      <a-menu v-for="menu in menu_list" v-model:selectedKeys="selectedKeys" theme="dark" mode="inline">
        <a-menu-item v-if="menu.children.length===0" :key="menu.id">

          <router-link :to="menu.menu_url">
            <desktop-outlined/>
            <span> {{ menu.title }}</span>
          </router-link>
        </a-menu-item>

        <a-sub-menu v-else :key="menu.id">
          <template #title>
            <span>
              <user-outlined/>
              <span>{{ menu.title }}</span>
            </span>
          </template>
          <a-menu-item v-for="child_menu in menu.children" :key="child_menu.id">
            <router-link :to="child_menu.menu_url">{{ child_menu.title }}</router-link>
          </a-menu-item>
        </a-sub-menu>
      </a-menu>
    </a-layout-sider>
    <a-layout>
      <a-layout-header style="background: #369; padding: 0"/>
      <a-layout-content style="margin: 0 16px">
        <router-view></router-view>
      </a-layout-content>
      <a-layout-footer style="text-align: center">
        Ant Design ©2018 Created by Ant UED
      </a-layout-footer>
    </a-layout>
  </a-layout>
</template>
<script>
import {DesktopOutlined, FileOutlined, PieChartOutlined, TeamOutlined, UserOutlined} from '@ant-design/icons-vue';
import {defineComponent, ref} from 'vue';

export default defineComponent({
  components: {
    PieChartOutlined,
    DesktopOutlined,
    UserOutlined,
    TeamOutlined,
    FileOutlined,
  },

  data() {
    return {
      collapsed: ref(false),
      selectedKeys: ref(['1']),
      menu_list: [
        {
          id: 1, icon: 'mail', title: '展示中心', tube: '', 'menu_url': '/urils/show_center', children: []
        },
        {
          id: 2, icon: 'mail', title: '资产管理', 'menu_url': '/urils/host', children: []
        },
        {
          "id": 3, icon: 'bold', title: '批量任务', tube: '', menu_url: '/urils/workbench', children: [
            {id: 10, icon: 'mail', title: '执行任务', 'menu_url': '/urils/multi_exec'},
            {id: 11, icon: 'mail', title: '命令管理', 'menu_url': '/urils/template_manage'},
          ]
        },
        {
          id: 4, icon: 'highlight', title: '代码发布', tube: '', menu_url: '/urils/workbench', children: [
            {id: 12, title: '应用管理', menu_url: '/urils/release'},
            {id: 13, title: '发布申请', menu_url: '/urils/release'}
          ]
        },
        {id: 5, icon: 'mail', title: '定时计划', tube: '', menu_url: '/urils/workbench', children: []},
        {
          id: 6, icon: 'mail', title: '配置管理', tube: '', menu_url: '/urils/workbench', children: [
            {id: 14, title: '环境管理', 'menu_url': '/urils/environment'},
            {id: 15, title: '服务配置', 'menu_url': '/urils/workbench'},
            {id: 16, title: '应用配置', 'menu_url': '/urils/workbench'}
          ]
        },
        {id: 7, icon: 'mail', title: '监控预警', tube: '', 'menu_url': '/urils/workbench', children: []},
        {
          id: 8, icon: 'mail', title: '报警', tube: '', 'menu_url': '/urils/workbench', children: [
            {id: 17, title: '报警历史', 'menu_url': '/urils/workbench'},
            {id: 18, title: '报警联系人', 'menu_url': '/urils/workbench'},
            {id: 19, title: '报警联系组', 'menu_url': '/urils/workbench'}
          ]
        },
        {
          id: 9, icon: 'mail', title: '用户管理', tube: '', menu_url: '/urils/workbench', children: [
            {id: 20, title: '账户管理', tube: '', menu_url: '/urils/workbench'},
            {id: 21, title: '角色管理', tube: '', menu_url: '/urils/workbench'},
            {id: 22, title: '系统设置', tube: '', menu_url: '/urils/workbench'}
          ]
        }
      ]
    };
  },

});
</script>
<style>
#components-layout-demo-side .logo {
  height: 32px;
  margin: 16px;
  background: rgba(255, 255, 255, 0.3);
}

.site-layout .site-layout-background {
  background: #fff;
}

[data-theme='dark'] .site-layout .site-layout-background {
  background: #141414;
}
</style>

image-20220525下午70712053

路由router/index.js:

import {createRouter, createWebHistory} from 'vue-router'
import Login from '../views/Login.vue'
import Base from '../views/Base'


const routes = [
    {
        meta: {
            title: 'uric自动化运维平台'
        },
        path: '/uric',
        alias: '/', // 给当前路径起一个别名
        name: 'Base',
        component: Base, // 快捷键:Alt+Enter快速导包

    },
    {
        meta: {
            title: '账户登陆'
        },
        path: '/login',
        name: 'Login',
        component: Login // 快捷键:Alt+Enter快速导包
    },
    {
        path: '/',
        name: 'ShowCenter',
        component: ShowCenter
    },
]

const router = createRouter({
    history: createWebHistory(process.env.BASE_URL),
    routes
})

export default router

接着我们再创建一个测试路由的组件,Host.vue,代码:

<template>
  <div class="host">
    <h1>host页面</h1>
  </div>
</template>

<script>
export default {
  name: 'Host'
}
</script>

<style scoped>

</style>

子路由配置

由于我们使用了组件嵌套,所以我们要通过路由嵌套来进行控制

Router/index.js

import {createRouter, createWebHistory} from 'vue-router'
import Login from '../views/Login.vue'
import Base from '../views/Base'
import ShowCenter from '../views/ShowCenter'
import Host from '../views/Host'


const routes = [
    {
        meta: {
            title: 'uric自动化运维平台'
        },
        path: '/uric',
        alias: '/', // 给当前路径起一个别名
        name: 'Base',
        component: Base, // 快捷键:Alt+Enter快速导包,
        children: [
            {
                path: 'show_center',
                alias: '', // 给当前路径起一个别名
                name: 'ShowCenter',
                component: ShowCenter
            },
            {
                path: 'host',
                name: 'Host',
                component: Host
            },
        ],
    },
    {
        meta: {
            title: '账户登陆'
        },
        path: '/login',
        name: 'Login',
        component: Login // 快捷键:Alt+Enter快速导包
    },

]

const router = createRouter({
    history: createWebHistory(process.env.BASE_URL),
    routes
})

export default router

image-20220323180353942

二 后端项目初始化

(1)虚拟环境

Python创建虚拟环境
创建虚拟环境是为了让项目运行在一个独立的局部的Python环境中,使得不同环境的项目互不干扰。

1. 安装虚拟环境的第三方包 virtualenv
pip install virtualenv
使用清华源安装:pip install virtualenv -i https://pypi.python.org/simple/

2. 创建虚拟环境
cd 到存放虚拟环境光的地址
virtualenv ENV 在当前目录下创建名为ENV的虚拟环境(如果第三方包virtualenv安装在python3下面,此时创建的虚拟环境就是基于python3的)
virtualenv -p 指定python版本创建虚拟环境 参数 
virtualenv -p /usr/local/bin/python3.6 虚拟环境名称 
virtualenv --system-site-packages ENV 参数 --system-site-packages 指定创建虚拟环境时继承系统三方库

3. 激活/退出虚拟环境
cd ~/ENV 跳转到虚拟环境的文件夹
source bin/activate 激活虚拟环境
pip list 查看当前虚拟环境下所安装的第三方库
deactivate 退出虚拟环境

4. 删除虚拟环境
直接删除虚拟环境所在目录即可

window系统没有bin文件夹,cd进入Scripts路径下运行:activate.bat

(2)搭建项目

基于Pycharm创建Django项目时可以直接构建虚拟环境

image-20220610下午62101239

可以直接在pycharm中使用创建好的虚拟环境,安装和查看第三方库。也可以在终端中使用虚拟环境,转到pycharm中设定的虚拟环境的位置,一般在工程的根目录。这个虚拟环境和上述用命令创建的虚拟环境一样,采用上述激活/退出虚拟环境命令即可执行相应操作。

测试,安装requests模块

image-20220610下午61820780

image-20220610下午61412150

(3)项目目录调整

# 默认结构
└── uric_api
    ├── manage.py
    └── uric_api
        ├── asgi.py
        ├── __init__.py
        ├── settings.py
        ├── urls.py
        └── wsgi.py

# 调整结构

└── uric_api         # 后端项目目录
    ├── __init__.py
    ├── logs         # 项目运行时/开发时日志目录
    ├── manage.py    # 开发阶段的启动文件
    ├── scripts      # 保存项目运营时的脚本文件 bash
    │   └── __init__.py
    └── uric_api     # 项目主应用,开发时的代码保存
        ├── apps     # 开发者的代码保存目录,以模块[子应用]为目录保存(包)
        │   └── __init__.py
        ├── asgi.py
        ├── __init__.py
        ├── libs              # 第三方类库的保存目录[第三方组件、模块](包)
        │   └── __init__.py
        ├── settings
        │   ├── dev.py         # 项目开发时的本地配置
        │   ├── __init__.py
        │   ├── prod.py        # 项目上线时的运行配置
        │   └── test.py        # 测试人员使用的配置(咱们不需要)
        ├── settings.py
        ├── urls.py            # 总路由(包) 
        ├── utils          # 多个模块[子应用]的公共函数类库[自己开发的组件]
        │   └── __init__.py
        └── wsgi.py

注意:创建文件夹的时候,是创建包(含__init__.py文件的)还是创建单纯的文件夹,看目录里面放的是什么,如果放的是py文件相关的代码,最好创建包,如果不是,那就创建单纯的文件夹。

切换manage.py启动项目时使用的配置文件。mange.py,代码:

image-20220610下午20536359

(4)注册DRF组件

下载:

pip install djangorestframework -i https://pypi.douban.com/simple

settings.dev.py,代码:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'rest_framework',
]

调整子应用保存以后,创建并注册子应用需要调整如下,

例如:创建home子应用

cd uric_api/apps
python ../../manage.py startapp home

子应用的注册,settings.dev.py,代码:

import sys
BASE_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(BASE_DIR / 'apps'))
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'rest_framework',
    "home"
]

创建了一个测试视图,提供给外界访问。home.views.py,代码:

from rest_framework.views import APIView
from rest_framework.response import Response
class TestAPIView(APIView):
    def get(self,request):
        return Response({"message":"hello"},)

home.urls.py,代码:

from django.urls import path
from . import views
urlpatterns = [
    path("test", views.TestAPIView.as_view())
]

总路由加载home子应用的路由信息,uric_api.urls,代码:

from django.contrib import admin
from django.urls import path,include

urlpatterns = [
    path('admin/', admin.site.urls),
    path("", include("home.urls")),
]

image-20220610下午64210493

(5)日志配置

参考django官方文档,网址:https://docs.djangoproject.com/zh-hans/3.2/topics/logging/

在settings/dev.py文件中追加如下配置:

# 日志配置
LOGGING = {
    # 使用的python内置的logging模块,那么python可能会对它进行升级,所以需要写一个版本号,目前就是1版本
    'version': 1,
    # 是否去掉目前项目中其他地方中以及使用的日志功能,但是将来我们可能会引入第三方的模块,里面可能内置了日志功能,所以尽量不要关闭,肯定False
    'disable_existing_loggers': False,
    # 日志的处理格式
    'formatters': {
        # 详细格式,往往用于记录日志到文件/其他第三方存储设备
        'verbose': {
            # levelname等级,asctime记录时间,module表示日志发生的文件名称,lineno行号,message错误信息
            'format': '{levelname} {asctime} {module}:{lineno:d} {message}',
            # 日志格式中的,变量分隔符
            'style': '{',
        },
        'simple': {  # 简单格式,往往用于终端
            'format': '{levelname} {module}:{lineno} {message}',
            'style': '{',
        },
    },
    'filters': { # 日志的过滤设置,可以对日志进行输出时的过滤用的
        # 在debug=True下产生的一些日志信息,要不要记录日志,需要的话就在handlers中加上这个过滤器,不需要就不加
        'require_debug_true': {
            '()': 'django.utils.log.RequireDebugTrue',
        },
    },
    'handlers': {  # 日志的处理方式
        'console': {  # 终端下显示
            'level': 'DEBUG',  # 日志的最低等级
            'filters': ['require_debug_true'],
            'class': 'logging.StreamHandler', # 处理日志的核心类
            'formatter': 'simple'
        },
        'file': {  # 文件中记录日志
            'level': 'INFO',
            'class': 'logging.handlers.RotatingFileHandler',
            # 日志位置,日志文件名,日志保存目录必须手动创建
            'filename': BASE_DIR.parent / "logs/uric.log",
            # 单个日志文件的最大值,这里我们设置300M
            'maxBytes': 300 * 1024 * 1024,
            # 备份日志文件的数量,设置最大日志数量为10
            'backupCount': 10,
            # 日志格式:详细格式
            'formatter': 'verbose',
            # 设置默认编码,否则打印出来汉字乱码
            'encoding': 'utf-8',
        },
    },
    # 日志实例对象
    'loggers': {
        'django': { # 固定名称,将来django内部也会有异常的处理,只会调用django下标的日志对象
            'handlers': ['console', 'file'],
            'propagate': True,  # 是否让日志信息继续冒泡给其他的日志处理系统
        },
      "DRF":{
            'handlers': ['file'],
            'propagate': True,  # 是否让日志信息继续冒泡给其他的日志处理系统     
      }
    }
}

案例:构建中间件,记录每次请求信息

from django.utils.deprecation import MiddlewareMixin

import logging
import time


class LogMiddleware(MiddlewareMixin):
    start = 0

    def process_request(self, request):
        self.start = time.time()

    def process_response(self, request, response):
        cost_timer = time.time() - self.start

        logger = logging.getLogger("django")
        if cost_timer > 0.5:
            logger.warning(f"请求路径: {request.path} 耗时{cost_timer}秒")

        return response

image-20220612下午43938550

(6)异常处理

新建utils/exceptions.py

import logging

from rest_framework.views import exception_handler
from rest_framework.response import Response
from rest_framework import status

from django.db import DatabaseError

logger = logging.getLogger("django")

def custom_exception_handler(exc, context):
    """
    自定义异常处理
    :param exc: 异常类实例对象
    :param context: 抛出异常的执行上下文[context,是一个字典格式的数据,里面记录了异常发生时的环境信息]
    :return: Response 响应对象
    """
    # 先让drf内置的异常处理函数先解决掉它能识别的异常
    response = exception_handler(exc, context)
    
    if response is None:
        """drf无法处理的异常"""
        view = context["view"]
        if isinstance(exc, DatabaseError):
            logger.error('[%s] %s' % (view, exc))
            response = Response({"errmsg":"服务器内部存储错误"}, status=status.HTTP_507_INSUFFICIENT_STORAGE)
        
    return response

settings/dev.py配置文件中添加

REST_FRAMEWORK = {
    # 异常处理
    'EXCEPTION_HANDLER': 'uric_api.utils.exceptions.custom_exception_handler',
}
# 视图更改
class TestAPIView(APIView):
    def get(self, request):
        from django.db import DatabaseError
        raise DatabaseError("mysql连接失败")
        return Response({"message": "hello"})

(7)连接数据库

create database uric default charset=utf8mb4; -- utf8也会导致有些极少的中文出现乱码的问题,mysql5.5之后官方才进行处理,出来了utf8mb4,这个是真正的utf8,能够容纳所有的中文。

为当前项目创建数据库用户[这个用户只能看到uric这个数据库]

# mysql8.0版本以上执行
# 创建用户:create user '用户名'@'主机地址' identified by '密码';
create user 'uricUser01'@'%' identified by 'uric';  # %表示任意主机都可以通过当前账户登录到mysql
# 分配权限:grant 权限选项 on 数据库名.数据表 to '用户名'@'主机地址' with grant option;
grant all privileges on uric.* to 'uricUser'@'%' with grant option;

# mysql8.0版本以下执行,创建数据库用户并设置数据库权限给当前新用户,并刷新内存中的权限记录
# create user uric_user identified by 'uric';
# grant all privileges on uric.* to 'uric_user'@'%';
# flush privileges;

配置数据库连接:打开settings/dev.py文件,并配置

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.mysql",
        "HOST": "127.0.0.1",
        "PORT": 3306,
        "USER": "uric_user",
        "PASSWORD": "uric",
        "NAME": "uric",
    }
}

在项目主模块的 __init__.py中导入pymysql

from pymysql import install_as_MySQLdb
install_as_MySQLdb()

注意是主模块的初始化文件,不是项目根目录的初始化文件!

初始化Django默认表:

python manage.py makemigrations
python manage.py migrate

初始化Django默认表:

python manage.py makemigrations
python manage.py migrate

image-20210819134957978

(8)跨域设置

cors解决跨域请求

我们现在为前端和后端分别设置两个不同的域名:

位置 域名
客户端 www.uric.cn
服务端 api.uric.cn

编辑/etc/hosts文件,可以设置本地域名

sudo vim /etc/hosts

window中在C:\Windows\System32\drivers\etc

在文件中增加两条信息

127.0.0.1   localhost
127.0.0.1   api.uric.cn
127.0.0.1   www.uric.cn

现在,前端与后端分处不同的域名,我们需要为后端添加跨域访问的支持,否则前端无法使用axios无法请求后端提供的api数据,开发中,我们使用CORS来解决后端对跨域访问的支持。CORS 即 Cross Origin Resource Sharing 跨域资源共享。

在 Response(headers={"Access-Control-Allow-Origin":'客户端地址或*'})
class CorsMiddleWare(MiddlewareMixin):

   def process_response(self, request, response):
       response["Access-Control-Allow-Origin"] = "*"

       return response

跨域复杂请求

跨域请求分两种:简单请求、复杂请求.

简单请求

简单请求必须满足下述条件.

HTTP方法为这三种方法之一:HEAD、GET、POST

HTTP头消息不超出以下字段:

Accept、Accept-Language、Content-Language、Last-Event-ID

且Content-Type只能为下列类型中的某一个:

- application/x-www-from-urlencoded
- multipart/form-data
- text/plain.

==任何不满足上述要求的请求,都会被认为是复杂请求.

复杂请求会先发出一个预请求——预检,OPTIONS请求.==

from django.utils.deprecation import MiddlewareMixin
 
class CorsMiddleWare(MiddlewareMixin):
  def process_response(self, request, response):
    # 如下,等于'*'后,便可允许所有简单请求的跨域访问
    response['Access-Control-Allow-Origin'] = '*'
 
    # 判断是否为复杂请求
    if request.method == 'OPTIONS':
      response['Access-Control-Allow-Headers'] = 'Content-Type'
      response['Access-Control-Allow-Methods'] = 'PUT,PATCH,DELETE'
 
    return response

cors-headers组件

文档:https://github.com/ottoyiu/django-cors-headers/

安装

pip install django-cors-headers -i https://pypi.douban.com/simple/

添加应用,settings.dev.py,代码:

INSTALLED_APPS = (
    ...
    'rest_framework',
    'corsheaders',
    ...
)

中间件设置【必须写在第一个位置】,settings.dev.py,代码:

MIDDLEWARE = [
    'corsheaders.middleware.CorsMiddleware', #放在中间件的最上面,就是给响应头加上了一个响应头跨域
    ...
]

需要添加跨域白名单,确定一下哪些客户端可以跨域。settings.dev.py,代码:

# CORS组的配置信息
CORS_ORIGIN_WHITELIST = (
    #'www.uric.cn:8080', # 如果这样写不行的话,就加上协议(http://www.uric.cn:8080,因为不同的corsheaders版本可能有不同的要求)
    'http://www.uric.cn:8080',
)
CORS_ALLOW_CREDENTIALS = False  # 是否允许ajax跨域请求时携带cookie,False表示不用,我们后面也用不到cookie,所以关掉它就可以了,以防有人通过cookie来搞我们的网站

允许客户端通过api.uric.cn访问Django项目,settings.dev.py,代码:

ALLOWED_HOSTS = ["api.uric.cn",]

完成了上面的步骤,我们将来就可以通过后端提供数据给前端使用ajax访问了。前端使用 axios就可以访问到后端提供给的数据接口,但是如果要附带cookie信息,前端还要设置一下,这个等我们搭建客户端项目时再配置。

(9)git设置

完成了上面的操作以后,服务端的初始化算基本完成了。我们现在已经写了那么多代码的话,肯定要对代码进行版本跟踪和管理,这时候,我们就要使用git通过gitee/github/gitlab进行创建代码版本。

码云:http://www.gitee.com

创建Git仓库:

image-20210819141635938

image-20210819141742504

# Git 全局设置:
git config --global user.name "Yuan先生"
git config --global user.email "916852314@163.com"
# 创建 git 仓库:
mkdir uric
cd uric
git init
touch README.md
git add README.md
git commit -m "first commit"
git remote add origin https://gitee.com/pythonyuan/uric.git
git push -u origin master

# 已有仓库
cd existing_git_repo
git remote add origin https://gitee.com/pythonyuan/uric.git
git push -u origin master

接下来,在终端下设置当前开发者的身份。这里,我们把客户端和服务端的代码保存到一个代码仓库中。所以直接在uric目录下创建代码仓库。

cd Desktop/
cd uric/
git init  # 初始化git代码库
# 设置身份
git config --global user.name "Yuan先生"
git config --global user.email "916852314@163.com"
# 设置远程仓库
git remote add origin https://gitee.com/pythonyuan/uric.git
# 接下来,我们就可以把上面服务端初始化的代码保存一个版本
git  add .
git commit -m "api服务端初始化完成"
# 生成本地ssh密码,链接到服务端
ssh-keygen -t rsa -C "916852314@qq.com"
# 查看上面生成的ssh公钥,并复制到gitee码云上面
cat ~/.ssh/id_rsa.pub  # ssh-rsa 
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDG/4nDPBNat3NgYdDM/ttxDTfRrlc5sH6KDgX+YXB8Zv8/YDJT7y2MPLPFTt/WXE4bxfFxn/5/87LELSbcOFz9VGzYSeZtysnX70rbxxP59/m6X6/oLiH4D++0zu5879gbHSOU5P5V0m1qofF4DD1so6R5FbO1aavFyIOt15IpKHLg9jkSIw3x6QSY3dojlnbR41Xu5XutdA1D1F3cjUjPQzGlMtnW3S79tocrLzHk2PDrqsDydvJGqQw//M9HCQqzZDUTAgMVldP8f0Pyzop4nnfrwPGf5uwWx0Pve6k4cpnGKwS0rnOcjU0fUqnbVq6Qaye5wR8IfFgoPMDBZCy4UAwMNtbP5YTx8nBVHr6b2N7ZNRYLZQXbPwra3ic8TmgLcUNyYsvNa98VTS56pLcSNKUBnSqY70OilbKAyysrPWN9Q5a69bbh4xwJRIf+7NEqvtBKpI2Beg7nXeWs2CS9pkJ5hwLIHtAzouwjrQZshVSjqg9n4R61AOOObwIpLLc= 916852314@qq.com


#  git remote remove origin
#  git remote add origin git@gitee.com:pythonyuan/uric.git

访问个人中心的设置ssh公钥的地址:https://gitee.com/profile/sshkeys

image-20210819145033188

# 把git版本库历史记录同步到gitee码云上面
git push -u origin master  # 此时就不需要提交用户名密码了

三 登录认证

3.1、创建users应用

创建用户模块的子应用

cd uric_api/apps
python ../../manage.py startapp users

在settings/dev.py文件中注册子应用。

INSTALLED_APPS = [
		...
  	'users',
]

子路由,users.urls.py,代码:

from django.urls import path
from . import views
urlpatterns = [

]

总路由注册users子路由,uric_api.urls.py,代码:

from django.contrib import admin
from django.urls import path,include

urlpatterns = [
    path('admin/', admin.site.urls),
    path("", include("home.urls")),
    path("users/", include("users.urls")),
]

3.2、创建自定义的用户模型类

Django默认已经提供了认证系统Auth模块,我们认证的时候,会使用auth模块里面给我们提供的表。认证系统包含:

  • 用户管理
  • 权限
  • 用户组
  • 密码哈希系统
  • 用户登录或内容显示的表单和视图
  • 一个可插拔的后台系统 admin

我们可以在终端下通过命令创建一个管理员账号,并登陆到admin站点中。

python manage.py migrate
python manage.py createsuperuser

Django默认用户的认证机制依赖Session机制,我们在项目中将引入JWT认证机制,将用户的身份凭据存放在Token中,然后对接Django的认证系统,帮助我们来实现:

  • 用户的数据模型
  • 用户密码的加密与验证
  • 用户的权限系统

Django认证系统中提供的用户模型类及方法很方便,我们可以使用这个模型类,但是字段有些无法满足项目需求,如本项目中需要保存用户的手机号,需要给模型类添加额外的字段。

Django提供了django.contrib.auth.models.AbstractUser用户抽象模型类允许我们继承,扩展字段来使用Django认证系统的用户模型类。

我们可以在apps中创建Django应用users,并在配置文件中注册users应用。

在创建好的应用models.py中定义用户的用户模型类。

from django.db import models
from django.contrib.auth.models import AbstractUser
# Create your models here.
class User(AbstractUser):
    mobile = models.CharField(max_length=15, unique=True, verbose_name='手机号码')
    # upload_to 表示上传文件的存储子路由,需要在settings配置中,配置上传文件的支持
    avatar = models.ImageField(upload_to='avatar', verbose_name='用户头像', null=True, blank=True)
    class Meta:
        db_table = 'uric_user'
        verbose_name = '用户信息'
        verbose_name_plural = verbose_name

# 下面的3个表现不用创建,留着以后使用
class Menu(models.Model):
    """
    一级菜单表
    """
    title = models.CharField(max_length=12)
    weight = models.IntegerField(default=0)
    icon = models.CharField(max_length=16, null=True, blank=True)

    def __str__(self):
        return self.title

    class Meta:
        db_table = 'uric_menu'
        verbose_name = '一级菜单表'
        verbose_name_plural = verbose_name
        unique_together = ('title', 'weight')


class Permission(models.Model):
    url = models.CharField(max_length=32)
    title = models.CharField(max_length=32)
    menus = models.ForeignKey('Menu',on_delete=models.CASCADE , null=True, blank=True)
    parent = models.ForeignKey('self',on_delete=models.CASCADE ,  null=True, blank=True)

    url_name = models.CharField(max_length=32, unique=True)

    def __str__(self):
        return self.title
    class Meta:
        db_table = 'uric_permission'
        verbose_name = '权限表'
        verbose_name_plural = verbose_name

class Role(models.Model):
    name = models.CharField(max_length=12)
    permissions = models.ManyToManyField(to='Permission')

    def __str__(self):
        return self.name

    class Meta:
        db_table = 'uric_role'
        verbose_name = '角色表'
        verbose_name_plural = verbose_name

创建用户模型对象两种方式:

User.objects.create_superuser()

Python manage.py createsuperuser

我们自定义的用户模型类还不能直接被Django的认证系统所识别,需要在配置文件中告知Django认证系统使用我们自定义的模型类。

在配置文件中进行设置,settings.dev.py,代码:

#设置Auth认证模块使用的用户模型为我们自己定义的用户模型
# 格式:“子应用目录名.模型类名”
AUTH_USER_MODEL = 'users.User'

AUTH_USER_MODEL 参数的设置以点.来分隔,表示应用名.模型类名

注意:Django建议我们对于AUTH_USER_MODEL参数的设置一定要在第一次数据库迁移之前就设置好,否则后续使用可能出现未知错误。

接下来,因为我们新建的用户模型需要同步到数据库中。所有需要数据迁移。同时当前用户模型中,我们声明了头像字段需要进行图片处理的。所以我们安装Pillow模块。

# 安装Pillow模块
pip install pillow

# 数据迁移
python manage.py makemigrations
python manage.py migrate

如果之前曾经进行了一次数据迁移,mysql中为原来的用户表与django其他的数据进行关联。此时我们再次数据迁移,因为修改了用户表,所以会出现外键关联报错。

1626509052126

解决方法如下:

1. 删除数据库中所有的数据表[如果数据表中有重要数据,必须先导出备份]
2. 删除当前users子应用中的migrations目录下所有以数字开头的python文件
3. 删除源码中django.contrib.auth和django.contrib.admin中的migrations目录下所有以数字开头的python文件
4. 重新执行数据迁移
   python manage.py makemigrations
   python manage.py migrate
5. 把上面备份的数据通过终端重新恢复

报错解决

1.数据库未设置utf8mb4
pymysql.err.DataError: (1366, "Incorrect string value: '\\xE7\\x94\\xA8\\xE6\\x88\\xB7...' for column 'name' at row 1")

2.数据库有列重复,直接删除数据库及django.contrib.auth和django.contrib.admin中的migrations子文件和当前子应用中的migrations子文件
django.db.utils.OperationalError: (1060, "Duplicate column name 'user_id'")

3.3、simpleui的安装和使用

simpleui是Django的第三方扩展,比使用Django的admin站点更强大也更方便,更好看。

文档:https://simpleui.72wo.com/docs/simpleui/

GitHub地址:https://github.com/happybeta/simpleui

通过如下命令安装simpleui的最新版,它文档里面的安装方法好久没有更新了,会导致你安装不成功,所以我们使用下面的网址进行安装

pip install django-simpleui -i https://pypi.douban.com/simple

在配置文件中注册如下应用,settings.dev,代码:

INSTALLED_APPS = [
    'simpleui',  # 必须写在django.contrib.admin之前
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'rest_framework',
    'corsheaders',
    'home',
    'users',
]

# 修改使用中文界面
LANGUAGE_CODE = 'zh-Hans'

# 修改时区
TIME_ZONE = 'Asia/Shanghai'

3.4、JWT

3.4.1、JWT介绍

在用户注册或登录后,我们想记录用户的登录状态,或者为用户创建身份认证的凭证。我们不再使用Session认证机制,而使用Json Web Token认证机制。

很多公司开发的一些移动端可能不支持cookie,并且我们通过cookie和session做接口登录认证的话,效率其实并不是很高,我们的接口可能提供给多个客户端,session数据保存在服务端,那么就需要每次都调用session数据进行验证,比较耗时,所以引入了token认证的概念,我们也可以通过token来完成,我们来看看jwt是怎么玩的。

Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519).该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。

JWT的构成

JWT就一段字符串,由三段信息构成的,将这三段信息文本用.链接一起就构成了Jwt字符串。就像这样:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

第一部分我们称它为头部(header),第二部分我们称其为载荷(payload, 类似于飞机上承载的物品),第三部分是签证(signature).

jwt的头部承载两部分信息:

  • 声明类型,这里是jwt
  • 声明加密的算法 通常直接使用 HMAC SHA256

完整的头部就像下面这样的JSON:

{
  'typ': 'JWT',
  'alg': 'HS256'
}

然后将头部进行base64.b64encode()编码,构成了第一部分.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

python中base64加密解密

import base64,json
data = {
  'typ': 'JWT',
  'alg': 'HS256'
}

header = base64.b64encode(json.dumps(data).encode()).decode()

# 各个语言中都有base64加密解密的功能,所以我们jwt为了安全,需要配合第三段加密

payload

载荷就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息可以存放下面三个部分信息。

  • 标准声明
  • 公共声明
  • 私有声明

标准声明 (建议但不强制使用) :

  • iss: jwt签发者

  • sub: jwt所面向的用户

  • aud: 接收jwt的一方

  • exp: jwt的过期时间,这个过期时间必须要大于签发时间

  • nbf: 定义在什么时间之前,该jwt都是不可用的.

  • iat: jwt的签发时间

  • jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。

    以上是JWT 规定的7个官方字段,供选用

公共声明 : 公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端直接可以查看.

私有声明 : 私有声明是服务端和客户端所共同定义的声明,一般使用了ace算法进行对称加密和解密的,意味着该部分信息可以归类为明文信息。

定义一个payload,json格式的数据:

{
  "sub": "1234567890",
  "exp": "3422335555", #时间戳形式
  "name": "John Doe",
  "admin": true,
  "info": "232323ssdgerere3335dssss"  # ACE算法加密
}

然后将其进行base64.b64encode() 编码,得到JWT的第二部分。

eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
import base64,json
data = {
  "sub": "1234567890",
  "exp": "3422335555",
  "name": "John Doe",
  "admin": True,
  "info": "232323ssdgerere3335dssss"
}

preload = base64.b64encode(json.dumps(data).encode()).decode()

# 各个语言中都有base64编码和解码,所以我们jwt为了安全,需要配合第三段签证来进行加密保证jwt不会被人篡改。

signature

JWT的第三部分是一个签证信息,这个签证信息由三部分组成:

  • header (base64后的)
  • preload (base64后的)
  • secret 密钥

这个部分需要base64加密后的header和base64加密后的payload使用.连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分。

python,代码:

import base64, json, hashlib

if __name__ == '__main__':
    # 头部
    data = {'typ': 'JWT', 'alg': 'HS256'}
    header = base64.b64encode(json.dumps(data).encode()).decode()

    # 载荷
    data = {"sub": "1234567890", "exp": "3422335555", "name": "John Doe", "admin": True,
            "info": "232323ssdgerere3335dssss"}
    preload = base64.b64encode(json.dumps(data).encode()).decode()

    # 签证
    # from django.conf import settings
    # secret = settings.SECRET_KEY
    secret = 'django-insecure-(_+qtd5edmhm%2rdsg+qc3wi@s_k*3cbk-+k2gpg3@qx)z6r+p'
    sign = f"{header}.{preload}.{secret}"

    hs256 = hashlib.sha256()
    hs256.update(sign.encode())
    signature = hs256.hexdigest()

    jwt = f"f{header}.{preload}.{signature}"
    print(jwt)

将这三部分用.连接成一个完整的字符串,构成了最终的jwt:

feyJ0eXAiOiAiSldUIiwgImFsZyI6ICJIUzI1NiJ9.eyJzdWIiOiAiMTIzNDU2Nzg5MCIsICJleHAiOiAiMzQyMjMzNTU1NSIsICJuYW1lIjogIkpvaG4gRG9lIiwgImFkbWluIjogdHJ1ZSwgImluZm8iOiAiMjMyMzIzc3NkZ2VyZXJlMzMzNWRzc3NzIn0=.374b156a33e579c780eb1594a5738c580a13ea0f905487dc66c15856b6110ebf

注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。

jwt的优点:
1. 实现分布式的单点登陆非常方便
2. 数据实际保存在客户端,所以我们可以分担服务端的存储压力
3. JWT不仅可用于认证,还可用于信息交换。善用JWT有助于减少服务器请求数据库的次数,jwt的构成非常简单,字节占用很小,所以它是非常便于传输的。

jwt的缺点:
1. 数据保存在了客户端,我们服务端只认jwt,不识别客户端。
2. jwt可以设置过期时间,但是因为数据保存在了客户端,所以对于过期时间不好调整。# secret_key轻易不要改,一改所有客户端都要重新登录

认证流程图

1626513913826

关于签发和核验JWT,我们可以使用Django REST framework JWT扩展来完成。

文档网站 ; https://jpadilla.github.io/django-rest-framework-jwt/

3.4.2、JWT安装与配置

(1)安装

pip install djangorestframework-jwt -i https://mirrors.aliyun.com/pypi/simple/

配置(github网址:https://github.com/jpadilla/django-rest-framework-jwt)

REST_FRAMEWORK = {
    # 自定义异常处理
    'EXCEPTION_HANDLER': 'uric_api.utils.exceptions.custom_exception_handler',
    # 自定义认证
    'DEFAULT_AUTHENTICATION_CLASSES': (
        # jwt认证
        'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
        # session认证
        'rest_framework.authentication.SessionAuthentication',

        'rest_framework.authentication.BasicAuthentication',
    ),
}

import datetime
JWT_AUTH = {
    # jwt的有效时间
    'JWT_EXPIRATION_DELTA': datetime.timedelta(weeks=1),
    'JWT_ALLOW_REFRESH': True,
}

我们django创建项目的时候,在settings配置文件中直接就给生成了一个serect_key,我们直接可以使用它作为我们jwt的serect_kek,其实djangorestframework-jwt默认配置中就使用的它。

(2)获取token内置函数

Django REST framework JWT提供了登录获取token的视图,可以直接给这视图指定url路由即可使用。

在users子应用路由urls.py中

from rest_framework_jwt.views import obtain_jwt_token

urlpatterns = [
    path('login/', obtain_jwt_token),
]

在主路由中,引入当前子应用的路由文件

urlpatterns = [
		...
    path('user/', include("users.urls")),
]

接下来,我们可以通过postman来测试下功能,但是jwt是通过username和password来进行登录认证处理的,所以我们要给真实数据,jwt会去我们配置的user表中去查询用户数据的。

添加测试用户命令:

python manage.py createsuperuser 
用户名:yuan 
密码:123

启动项目:

python manage.py runserver api.uric.cn:8000

image-20220614下午43120557

得到的载荷信息,我们可以通过js内置的base64编码函数来读取里面内容。举例代码:

let token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6InJvb3QiLCJleHAiOjE2MjcxMTkzODQsImVtYWlsIjoiIn0.Xz_QJ5BPSOsjIB-EymwHaptgG-v1Ic8Aa0FhYhcEErE"
let data = token.split(".")
let user_info = JSON.parse(atob(data[1]))   

// atob()   // base64解码
// btoa()   // base64编码

image-20220614下午43713425

(3)验证token的有效性

配置路由

from django.urls import path
from . import views
from rest_framework_jwt.views import obtain_jwt_token,verify_jwt_token,refresh_jwt_token
urlpatterns = [
    path('login/', obtain_jwt_token),
    path('verify/', verify_jwt_token),  # 这是只是校验token有效性
    path(r'refresh_jwt_token/', refresh_jwt_token),  # 校验并生成新的token
]

刷新token除了配置上面的refresh_jwt_token之外,还需要在配置文件中加上如下配置

JWT_AUTH = {
    # 'JWT_SECRET_KEY': settings.SECRET_KEY,
    'JWT_EXPIRATION_DELTA': datetime.timedelta(seconds=30),
    'JWT_ALLOW_REFRESH': True,  # 这个参数要改True,才能刷新token
}

注意:必须在请求体中token键值对方式请求

postman测试:

先获取token:

image-20220614下午50719522

验证token,有效期内通过:

image-20220614下午50811708

过了有效期:

image-20220614下午50837025

继续测试refresh_jwt_token的刷新token的功能:

image-20220614下午52211646

3.5、登录认证客户端

3.5.1 本地存储token

image-20220615上午90119949

<template>
    <div class="login box">
        <img src="../assets/login.jpg" alt="">
        <div class="login">
            <div class="login-title">
                <p class="hi">Hello,Uric!</p>
            </div>
            <div class="login_box">
                <div class="title">
                    <span>登录</span>
                </div>
                <div class="inp">
                    <a-input v-model:value="username" type="text" placeholder="用户名" class="user"></a-input>
                    <a-input v-model:value="password" type="password" class="pwd" placeholder="密码"></a-input>
                    <div class="rember">
                        <p>
                            <a-checkbox v-model:checked="remember">记住密码</a-checkbox>
                        </p>
                    </div>
                    <button class="login_btn" @click="login">登录</button>

                </div>

            </div>
        </div>
    </div>
</template>

<script>
    import axios from "axios"

    export default {
        name: 'Login',
        data() {
            return {
                username: '',
                password: '',
                remember: false
            }
        },

        methods: {

            login() {
               
                axios.post(this.$settings.host + `/users/login/`, {
                    username: this.username,
                    password: this.password,
                }).then((response) => {
                    // locatStorage或者sessionStorage中存储token
                    // 先清空原有的token
                    localStorage.removeItem("token");
                    sessionStorage.removeItem("token");

                    if (this.remember) {
                        // 记住登录
                        localStorage.token = response.data.token;
                    } else {
                        sessionStorage.token = response.data.token;
                    }
                    // 跳转到首页
                    let self = this;
                    this.$success({
                        title: 'uric系统提示',
                        content: `登录成功!`,
                        onOk() {
                            self.$router.push("/uric");
                        }
                    })
                    // 下一个页面,首页加载时验证token有效性
                }).catch(error => {
                    this.$message.error('用户名或者密码有误,请重新输入!');
                });

            }
        }

    }
</script>

<style scoped>
    .login .hi {
        font-size: 20px;
        font-family: "Times New Roman";
        font-style: italic;
    }

    .box {
        width: 100%;
        height: 100%;
        position: relative;
        overflow: hidden;
    }

    .box img {
        width: 100%;
        min-height: 100%;
    }

    .box .login {
        position: absolute;
        width: 500px;
        height: 400px;
        left: 0;
        margin: auto;
        right: 0;
        bottom: 0;
        top: -338px;
    }

    .login .login-title {
        width: 100%;
        text-align: center;
    }

    .login-title img {
        width: 190px;
        height: auto;
    }

    .login-title p {
        font-size: 18px;
        color: #fff;
        letter-spacing: .29px;
        padding-top: 10px;
        padding-bottom: 50px;
    }

    .login_box {
        width: 400px;
        height: auto;
        background: rgba(255, 255, 255, 0.3);
        box-shadow: 0 2px 4px 0 rgba(0, 0, 0, .5);
        border-radius: 4px;
        margin: 0 auto;
        padding-bottom: 40px;
    }

    .login_box .title {
        font-size: 20px;
        color: #9b9b9b;
        letter-spacing: .32px;
        border-bottom: 1px solid #e6e6e6;
        display: flex;
        justify-content: space-around;
        padding: 50px 60px 0 60px;
        margin-bottom: 20px;
        cursor: pointer;
    }

    .login_box .title span:nth-of-type(1) {
        color: #4a4a4a;
        border-bottom: 2px solid #396fcc;
    }

    .inp {
        width: 350px;
        margin: 0 auto;
    }

    .inp input {
        outline: 0;
        width: 100%;
        height: 45px;
        border-radius: 4px;
        border: 1px solid #d9d9d9;
        text-indent: 20px;
        font-size: 14px;
        background: #fff !important;
    }

    .inp input.user {
        margin-bottom: 16px;
    }

    .inp .rember {
        display: flex;
        justify-content: space-between;
        align-items: center;
        position: relative;
        margin-top: 10px;
    }

    .inp .rember p:first-of-type {
        font-size: 12px;
        color: #4a4a4a;
        letter-spacing: .19px;
        margin-left: 22px;
        display: -ms-flexbox;
        display: flex;
        -ms-flex-align: center;
        align-items: center;
        /*position: relative;*/
    }

    .inp .rember p:nth-of-type(2) {
        font-size: 14px;
        color: #9b9b9b;
        letter-spacing: .19px;
        cursor: pointer;
    }

    .inp .rember input {
        outline: 0;
        width: 30px;
        height: 45px;
        border-radius: 4px;
        border: 1px solid #d9d9d9;
        text-indent: 20px;
        font-size: 14px;
        background: #fff !important;
    }

    .inp .rember p span {
        display: inline-block;
        font-size: 12px;
        width: 100px;
        /*position: absolute;*/
        /*left: 20px;*/

    }

    #geetest {
        margin-top: 20px;
    }

    .login_btn {
        width: 100%;
        height: 45px;
        background: #396fcc;
        border-radius: 5px;
        font-size: 16px;
        color: #fff;
        letter-spacing: .26px;
        margin-top: 30px;
    }

    .inp .go_login {
        text-align: center;
        font-size: 14px;
        color: #9b9b9b;
        letter-spacing: .26px;
        padding-top: 20px;
    }

    .inp .go_login span {
        color: #84cc39;
        cursor: pointer;
    }
</style>

3.5.2 基于vuex对本地数据持久化存储

Vuex 是一个专门为 Vue.js 应用程序开发的状态管理模式,它采用集中式存储管理应用的所有组件状态,并以相应的规则保证状态以一种可预测的方式发生变化。可以理解为:将多个组件共享的变量全部存储在一个对象里面,然后将这个对象放在顶层的 Vue 实例中,让其他组件可以使用,它最大的特点是响应式。

一般情况下,我们会在 Vuex 中存放一些需要在多个界面中进行共享的信息。比如用户的登录状态、用户名称、头像、地理位置信息、商品的收藏、购物车中的物品等,这些状态信息,我们可以放在统一的地方,对它进行保存和管理。

安装依赖包:

npm install vuex
import createPersistedState from 'vuex-persistedstate'

在src路径下创建store文件夹下创建index.js文件

import {createStore} from 'vuex'

import createPersistedState from 'vuex-persistedstate'

export default createStore({
    state: {
        token: '',
        remember: true
    },
    plugins: [createPersistedState({ // setState,getState自动触发,防止刷新vuex清空,所以存到本地
        key: 'vuex',
        setState(key, state) {
            if (state.remember) {
                localStorage[key] = JSON.stringify(state)
            } else {
                sessionStorage[key] = JSON.stringify(state)
            }

        },
        getState(key, state) {
            if (localStorage[key]) {
                return JSON.parse(localStorage[key])
            } else {
                return JSON.parse(sessionStorage[key])
            }
        }
    })],
    getters: {
        get_user_info(state, getters) {
            let data = state.token.split(".")
            return JSON.parse(atob(data[1]))
        },
        token(state, getters) {
            return state.token
        },
        remember(state, getters) {
            return state.remember
        }
    },
    mutations: {  // 类似methods
        setToken(state, token) {
            // 设置本地保存token
            state.token = token
        },
        setRemember(state, remember) {
            // 设置记住登陆状态
            // localStorage.removeItem('vuex');
            // sessionStorage.removeItem('vuex');
            state.remember = remember
        },
    },
    actions: {},
    modules: {}
})

在main.js中加入代码:

import store from './store'
app.use(store).use(router).use(Antd).mount('#app');

Login.vue代码更新为:

<template>
    <div class="login box">
        <img src="../assets/login.jpg" alt="">
        <div class="login">
            <div class="login-title">
                <p class="hi">Hello,Urils!</p>
            </div>
            <div class="login_box">
                <div class="title">
                    <span>登录</span>
                </div>
                <div class="inp">
                    <a-input v-model:value="username" type="text" placeholder="用户名" class="user"></a-input>
                    <a-input v-model:value="password" type="password" class="pwd" placeholder="密码"></a-input>
                    <div class="rember">
                        <p>
                            <a-checkbox v-model:checked="remember">记住密码</a-checkbox>
                        </p>
                    </div>
                    <button class="login_btn" @click="login">登录</button>

                </div>

            </div>
        </div>
    </div>
</template>
<script>
    import axios from "axios"

    export default {
        name: 'Login',
        data() {
            return {
                username: 'yuan',
                password: '123',
                remember: true // 记录登陆状态
            }
        },
        watch: { // 监听数据是否发生变化
            remember() {
                this.$store.commit('setRemember', this.remember);
            }
        },
        created() {
            // 默认用户没有记录登陆状态
            this.remember = this.$store.state.remember;
        },
        methods: {
            login() {
                axios.post(`${this.$settings.host}/users/login/`, {
                    username: this.username,
                    password: this.password
                }).then(response => {

                    // vuex存储token
                    this.$store.commit('setToken', response.data.token);

                    let self = this;
                    this.$success({
                        title: 'Uric系统提示',
                        content: '登陆成功!欢迎回来!',
                        onOk() {
                            // 在这里,不能直接使用this,因为此处的this被重新赋值了,不再是原来的外界的vue对象了,而是一个antd-vue提供的对话框对象了
                            self.$router.push('/')
                        }
                    })
                }).catch((res) => {
                    // 登陆失败!
                    this.$message.error('用户名或者密码有误,请重新输入!');
                })
            }

        }

    }
</script>


<style scoped>
    .login .hi {
        font-size: 20px;
        font-family: "Times New Roman";
        font-style: italic;
    }

    .box {
        width: 100%;
        height: 100%;
        position: relative;
        overflow: hidden;
    }

    .box img {
        width: 100%;
        min-height: 100%;
    }

    .box .login {
        position: absolute;
        width: 500px;
        height: 400px;
        left: 0;
        margin: auto;
        right: 0;
        bottom: 0;
        top: -338px;
    }

    .login .login-title {
        width: 100%;
        text-align: center;
    }

    .login-title img {
        width: 190px;
        height: auto;
    }

    .login-title p {
        font-size: 18px;
        color: #fff;
        letter-spacing: .29px;
        padding-top: 10px;
        padding-bottom: 50px;
    }

    .login_box {
        width: 400px;
        height: auto;
        background: rgba(255, 255, 255, 0.3);
        box-shadow: 0 2px 4px 0 rgba(0, 0, 0, .5);
        border-radius: 4px;
        margin: 0 auto;
        padding-bottom: 40px;
    }

    .login_box .title {
        font-size: 20px;
        color: #9b9b9b;
        letter-spacing: .32px;
        border-bottom: 1px solid #e6e6e6;
        display: flex;
        justify-content: space-around;
        padding: 50px 60px 0 60px;
        margin-bottom: 20px;
        cursor: pointer;
    }

    .login_box .title span:nth-of-type(1) {
        color: #4a4a4a;
        border-bottom: 2px solid #396fcc;
    }

    .inp {
        width: 350px;
        margin: 0 auto;
    }

    .inp input {
        outline: 0;
        width: 100%;
        height: 45px;
        border-radius: 4px;
        border: 1px solid #d9d9d9;
        text-indent: 20px;
        font-size: 14px;
        background: #fff !important;
    }

    .inp input.user {
        margin-bottom: 16px;
    }

    .inp .rember {
        display: flex;
        justify-content: space-between;
        align-items: center;
        position: relative;
        margin-top: 10px;
    }

    .inp .rember p:first-of-type {
        font-size: 12px;
        color: #4a4a4a;
        letter-spacing: .19px;
        margin-left: 22px;
        display: -ms-flexbox;
        display: flex;
        -ms-flex-align: center;
        align-items: center;
        /*position: relative;*/
    }

    .inp .rember p:nth-of-type(2) {
        font-size: 14px;
        color: #9b9b9b;
        letter-spacing: .19px;
        cursor: pointer;
    }

    .inp .rember input {
        outline: 0;
        width: 30px;
        height: 45px;
        border-radius: 4px;
        border: 1px solid #d9d9d9;
        text-indent: 20px;
        font-size: 14px;
        background: #fff !important;
    }

    .inp .rember p span {
        display: inline-block;
        font-size: 12px;
        width: 100px;
        /*position: absolute;*/
        /*left: 20px;*/

    }

    #geetest {
        margin-top: 20px;
    }

    .login_btn {
        width: 100%;
        height: 45px;
        background: #396fcc;
        border-radius: 5px;
        font-size: 16px;
        color: #fff;
        letter-spacing: .26px;
        margin-top: 30px;
    }

    .inp .go_login {
        text-align: center;
        font-size: 14px;
        color: #9b9b9b;
        letter-spacing: .26px;
        padding-top: 20px;
    }

    .inp .go_login span {
        color: #84cc39;
        cursor: pointer;
    }
</style>

image-20220615下午121233037

3.5.3、后端token验证

配置文件:

REST_FRAMEWORK = {
    # 自定义异常处理
    'EXCEPTION_HANDLER': 'uric_api.utils.exceptions.custom_exception_handler',
    # 自定义认证
    'DEFAULT_AUTHENTICATION_CLASSES': (
        # jwt认证
        'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
    ),

    # 'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',)

}

视图更新为:

from rest_framework.permissions import IsAuthenticated

class HostView(APIView):
    permission_classes = [IsAuthenticated, ]

    def get(self, reqeust):
        data = [
                   {
                       'id': 1,
                       'category_name': '数据库服务器',
                       'name': 'izbp13e05jqwodd605vm3gz',
                       'ip_addr': '47.58.131.12',
                       'port': 22,
                       'remark': ''
                   },
                   {
                       'id': 2,
                       'category_name': '数据库服务器',
                       'name': 'iZbp1a3jw4l12ho53ivhkkZ',
                       'ip_addr': '12.18.125.22',
                       'port': 22,
                       'remark': ''
                   },
                   {
                       'id': 3,
                       'category_name': '缓存服务器',
                       'name': 'iZbp1b1xqfqw257gs563k2iZ',
                       'ip_addr': '12.19.135.130',
                       'port': 22,
                       'remark': ''
                   },
                   {
                       'id': 4,
                       'category_name': '缓存服务器',
                       'name': 'iZbp1b1jw4l01ho53muhkkZ',
                       'ip_addr': '47.98.101.89',
                       'port': 22,
                       'remark': ''
                   }
               ],
        return Response(data)

image-20220617下午33536492

image-20220617下午33601994

3.5.4、路由守卫

Host.vue发送ajax请求

<template>
  <h3>主机管理</h3>
</template>

<script>
import axios from "axios";

export default {
  name: "Host",
  mounted() {
    axios.get(this.$settings.host + "/host", {
      headers: {
        "Authorization": "jwt " + this.$store.getters.token
      }
    }).then((res) => {
      console.log("res:", res)

    }).catch((err) => {
      console.log("err:", err)
      this.$router.push("/login")
    })

  }
}
</script>

<style scoped>

</style>

有些页面需要验证才能访问,可以通过路由守卫完成

import {createRouter, createWebHistory} from 'vue-router'
import Login from '../views/Login.vue'
import Base from '../views/Base'
import ShowCenter from '../views/ShowCenter'
import Host from '../views/Host'
import store from "../store"


const routes = [
    {

        path: '/uric',
        alias: '/', // 给当前路径起一个别名
        name: 'Base',
        component: Base, // 快捷键:Alt+Enter快速导包,
        children: [
            {
                meta: {
                    title: '展示中心',
                    authenticate: false,
                },

                path: 'show_center',
                alias: '', // 给当前路径起一个别名
                name: 'ShowCenter',
                component: ShowCenter
            },
            {
                meta: {
                    title: '主机管理',
                    authenticate: true,
                },
                path: 'host',
                name: 'Host',
                component: Host
            },

        ],
    },
    {
        meta: {
            title: '账户登陆'
        },
        path: '/login',
        name: 'Login',
        component: Login // 快捷键:Alt+Enter快速导包
    },


];

const router = createRouter({
    history: createWebHistory(process.env.BASE_URL),
    routes
});


// 路由守卫,主要是编写一些在页面跳转过程中, 需要自动执行的代码。例如:修改页面头部标题,验证权限。
router.beforeEach((to, from, next) => {
    alert("页面跳转");
    document.title = to.meta.title;
    console.log("token:::", store.getters.token);
    if (to.meta.authenticate && (store.getters.token === '')) {
        // 如果访问需要登录的页面,但是没有token则默认跳转到login登陆页面
        next({name: 'Login'})
    } else {
        next()
    }
});

export default router


3.5.5、注销

<template>
  <a-layout style="min-height: 100vh">
    <a-layout-sider v-model:collapsed="collapsed" collapsible>
      <div class="logo"
           style="font-style: italic;text-align: center;font-size: 20px;color:#fff;margin: 10px 0;line-height: 50px;font-family: 'Times New Roman'">
        <span><a-switch v-model:checked="checked"/> DevOps</span>
      </div>
      <div class="logo"/>
      <a-menu v-for="menu in menu_list" v-model:selectedKeys="selectedKeys" theme="dark" mode="inline">
        <a-menu-item v-if="menu.children.length===0" :key="menu.id">

          <router-link :to="menu.menu_url">
            <desktop-outlined/>
            <span> {{ menu.title }}</span>
          </router-link>
        </a-menu-item>

        <a-sub-menu v-else :key="menu.id">
          <template #title>
            <span>
              <user-outlined/>
              <span>{{ menu.title }}</span>
            </span>
          </template>
          <a-menu-item v-for="child_menu in menu.children" :key="child_menu.id">
            <router-link :to="child_menu.menu_url">{{ child_menu.title }}</router-link>
          </a-menu-item>
        </a-sub-menu>
      </a-menu>
    </a-layout-sider>
    <a-layout>
      <a-layout-header style="background: #fff; padding: 20px">


        <a-row type="flex" justify="start">

          <a-col :span="6">
            <a-breadcrumb>
              <a-breadcrumb-item href="">
                <home-outlined/>
              </a-breadcrumb-item>
              <a-breadcrumb-item href="">
                <user-outlined/>
                <span>Application List</span>
              </a-breadcrumb-item>
              <a-breadcrumb-item>Application</a-breadcrumb-item>
            </a-breadcrumb>
          </a-col>

          <a-col :span="1" :offset="17">
            <a-breadcrumb>
              <a-button @click="logout" type="primary" class="logout">
                注销
              </a-button>
            </a-breadcrumb>
          </a-col>

        </a-row>

      </a-layout-header>

      <a-layout-content style="margin: 0 16px">

        <router-view></router-view>

      </a-layout-content>
      <a-layout-footer style="text-align: center">
        Ant Design ©2018 Created by Ant UED
      </a-layout-footer>
    </a-layout>
  </a-layout>
</template>
<script>


import {
  DesktopOutlined,
  FileOutlined,
  PieChartOutlined,
  TeamOutlined,
  UserOutlined,
  HomeOutlined
} from '@ant-design/icons-vue';
import {defineComponent, ref} from 'vue';

export default defineComponent({
  setup() {
    const checked = ref(true);
    return {
      checked,
    };
  },
  components: {
    PieChartOutlined,
    DesktopOutlined,
    UserOutlined,
    TeamOutlined,
    FileOutlined,
    HomeOutlined,
  },

  data() {
    return {
      collapsed: ref(false),
      selectedKeys: ref(['1']),
      menu_list: [
        {
          id: 1, icon: 'mail', title: '展示中心', tube: '', 'menu_url': '/uric/show_center', children: []
        },
        {
          id: 2, icon: 'mail', title: '资产管理', 'menu_url': '/uric/host', children: []
        },
        {
          "id": 3, icon: 'bold', title: '批量任务', tube: '', menu_url: '/uric/workbench', children: [
            {id: 10, icon: 'mail', title: '执行任务', 'menu_url': '/uric/multi_exec'},
            {id: 11, icon: 'mail', title: '命令管理', 'menu_url': '/uric/template_manage'},
          ]
        },
        {
          id: 4, icon: 'highlight', title: '代码发布', tube: '', menu_url: '/uric/workbench', children: [
            {id: 12, title: '应用管理', menu_url: '/uric/release'},
            {id: 13, title: '发布申请', menu_url: '/uric/release'}
          ]
        },
        {id: 5, icon: 'mail', title: '定时计划', tube: '', menu_url: '/uric/workbench', children: []},
        {
          id: 6, icon: 'mail', title: '配置管理', tube: '', menu_url: '/uric/workbench', children: [
            {id: 14, title: '环境管理', 'menu_url': '/uric/environment'},
            {id: 15, title: '服务配置', 'menu_url': '/uric/workbench'},
            {id: 16, title: '应用配置', 'menu_url': '/uric/workbench'}
          ]
        },
        {id: 7, icon: 'mail', title: '监控预警', tube: '', 'menu_url': '/uric/workbench', children: []},
        {
          id: 8, icon: 'mail', title: '报警', tube: '', 'menu_url': '/uric/workbench', children: [
            {id: 17, title: '报警历史', 'menu_url': '/uric/workbench'},
            {id: 18, title: '报警联系人', 'menu_url': '/uric/workbench'},
            {id: 19, title: '报警联系组', 'menu_url': '/uric/workbench'}
          ]
        },
        {
          id: 9, icon: 'mail', title: '用户管理', tube: '', menu_url: '/uric/workbench', children: [
            {id: 20, title: '账户管理', tube: '', menu_url: '/uric/workbench'},
            {id: 21, title: '角色管理', tube: '', menu_url: '/uric/workbench'},
            {id: 22, title: '系统设置', tube: '', menu_url: '/uric/workbench'}
          ]
        }
      ]
    };
  },
  methods: {
    logout() {
      let self = this;
      this.$confirm({
        title: 'Uric系统提示',
        content: '您确认要注销登陆吗?',
        onOk() {
          self.$store.commit('setToken', '')
          self.$router.push('/login')
        }
      })
    },
  }

});
</script>
<style>
#components-layout-demo-side .logo {
  height: 32px;
  margin: 16px;
}

.site-layout .site-layout-background {
  background: #fff;
}

[data-theme='dark'] .site-layout .site-layout-background {
  background: #141414;
}

.logout {
  line-height: 1.5715;
}
</style>

image-20220622165429932

四 主机管理

4.1、主机基本功能

4.1.1、前端页面初始化

image-20220623184312459

Host.vue代码:

<template>
  <a-row>
    <a-col :span="6">
      <div class="add_host" style="margin: 15px;">

        <a-button @click="showHostModal" type="primary">
          新建
        </a-button>
        <a-button type="primary" style="margin-left: 20px;">
          批量导入
        </a-button>
      </div>
    </a-col>

  </a-row>
  <a-table bordered :dataSource="hostList.data" :columns="hostFormColumns">
    <template #bodyCell="{ column, text, record }">
      <template v-if="column.dataIndex === 'action'">
        <a-popconfirm
            v-if="hostList.data.length"
            title="Sure to delete?"
            @confirm="deleteHost(record)"
        >
          <a>Delete</a>
        </a-popconfirm>
      </template>
    </template>

  </a-table>

  <div>

    <a-modal v-model:visible="hostFormVisible" title="添加主机" @ok="onHostFormSubmit" @cancel="resetForm()" :width="800">
      <a-form
          ref="formRef"
          name="custom-validation"
          :model="hostForm.form"
          :rules="hostForm.rules"
          v-bind="layout"
          @finish="handleFinish"
          @validate="handleValidate"
          @finishFailed="handleFinishFailed"
      >
        <a-form-item label="主机类别" prop="zone" name="category">
          <a-row>
            <a-col :span="12">
              <a-select
                  ref="select"
                  v-model:value="hostForm.form.category"
                  @change="handleCategorySelectChange"

              >
                <a-select-option :value="category.id" v-for="category in categoryList.data" :key="category.id">
                  {{ category.name }}
                </a-select-option>
              </a-select>
            </a-col>

          </a-row>

        </a-form-item>

        <a-form-item has-feedback label="主机名称" name="name">
          <a-input v-model:value="hostForm.form.name" type="text" autocomplete="off"/>
        </a-form-item>


        <a-form-item has-feedback label="连接地址" name="username">

          <a-row>
            <a-col :span="8">
              <a-input placeholder="用户名" addon-before="ssh" v-model:value="hostForm.form.username" type="text"
                       autocomplete="off"/>
            </a-col>
            <a-col :span="8">
              <a-input placeholder="ip地址" addon-before="@" v-model:value="hostForm.form.ip_addr" type="text"
                       autocomplete="off"/>
            </a-col>
            <a-col :span="8">
              <a-input placeholder="端口号" addon-before="-p" v-model:value="hostForm.form.port" type="text"
                       autocomplete="off"/>
            </a-col>
          </a-row>
        </a-form-item>

        <a-form-item has-feedback label="连接密码" name="password">
          <a-input v-model:value="hostForm.form.password" type="password" autocomplete="off"/>
        </a-form-item>

        <a-form-item has-feedback label="备注信息" name="remark">
          <a-textarea placeholder="请输入主机备注信息" v-model:value="hostForm.form.remark" type="text"
                      :auto-size="{ minRows: 3, maxRows: 5 }" autocomplete="off"/>
        </a-form-item>


        <a-form-item :wrapper-col="{ span: 14, offset: 4 }">
          <a-button @click="resetForm">Reset</a-button>
        </a-form-item>
      </a-form>


    </a-modal>
    <a-modal
        :width="600"
        title="新建主机类别"
        :visible="HostCategoryFromVisible"
        @cancel="hostCategoryFormCancel"
    >
      <template #footer>
        <a-button key="back" @click="hostCategoryFormCancel">取消</a-button>
        <a-button key="submit" type="primary" :loading="loading" @click="onHostCategoryFromSubmit">提交</a-button>
      </template>
      <a-form-model ref="hostCategoryRuleForm" v-model:value="hostCategoryForm.form" :rules="hostCategoryForm.rules"
                    :label-col="hostCategoryForm.labelCol" :wrapper-col="hostCategoryForm.wrapperCol">
        <a-form-model-item ref="name" label="类别名称" prop="name">
          <a-row>
            <a-col :span="24">
              <a-input placeholder="请输入主机类别名称" v-model:value="hostCategoryForm.form.name"/>
            </a-col>
          </a-row>
        </a-form-model-item>
      </a-form-model>
    </a-modal>
  </div>

</template>
<script>
import {defineComponent, ref, reactive} from 'vue';
import axios from "axios";
import settings from "@/settings";
import store from "@/store";
import {message} from 'ant-design-vue';

export default {
  setup() {

    const handleChange = value => {
      console.log(`selected ${value}`);
    };

    const handleCategorySelectChange = (value) => {
      // 切换主机类别的回调处理
      console.log(value)
    };


    const formRef = ref();
    const HostCategoryFromVisible = ref(false);
    const hostList = reactive({
      data: [
        {
          'id': 1,
          'category_name': '数据库服务器',
          'name': 'izbp13e05jqwodd605vm3gz',
          'ip_addr': '47.58.131.12',
          'port': 22,
          'remark': ''
        },
        {
          'id': 2,
          'category_name': '数据库服务器',
          'name': 'iZbp1a3jw4l12ho53ivhkkZ',
          'ip_addr': '12.18.125.22',
          'port': 22,
          'remark': ''
        },
        {
          'id': 3,
          'category_name': '缓存服务器',
          'name': 'iZbp1b1xqfqw257gs563k2iZ',
          'ip_addr': '12.19.135.130',
          'port': 22,
          'remark': ''
        },
        {
          'id': 4,
          'category_name': '缓存服务器',
          'name': 'iZbp1b1jw4l01ho53muhkkZ',
          'ip_addr': '47.98.101.89',
          'port': 22,
          'remark': ''
        }
      ]
    })
    const categoryList = reactive({
      data: [
        {'id': 1, 'name': '数据库服务'},
        {'id': 2, 'name': '缓存服务'},
        {'id': 3, 'name': 'web服务'},
        {'id': 4, 'name': '静态文件存储服务'}
      ]
    })

    const hostForm = reactive({
      labelCol: {span: 6},
      wrapperCol: {span: 14},
      other: '',
      form: {
        name: '',
        category: "",
        ip_addr: '',
        username: '',
        port: '',
        remark: '',
        password: ''
      },
      rules: {
        name: [
          {required: true, message: '请输入主机名称', trigger: 'blur'},
          {min: 3, max: 10, message: '长度在3-10位之间', trigger: 'blur'}
        ],
        password: [
          {required: true, message: '请输入连接密码', trigger: 'blur'},
          {min: 3, max: 10, message: '长度在3-10位之间', trigger: 'blur'}
        ],
        category: [
          {required: true, message: '请选择类别', trigger: 'change'}
        ],
        username: [
          {required: true, message: '请输入用户名', trigger: 'blur'},
          {min: 3, max: 10, message: '长度在3-10位', trigger: 'blur'}
        ],
        ip_addr: [
          {required: true, message: '请输入连接地址', trigger: 'blur'},
          {max: 15, message: '长度最大15位', trigger: 'blur'}
        ],
        port: [
          {required: true, message: '请输入端口号', trigger: 'blur'},
          {max: 5, message: '长度最大5位', trigger: 'blur'}
        ]
      }
    });
    let validateName = async (_rule, value) => {
      if (value === '') {
        return Promise.reject('请输入类别名称');
      } else {
        return Promise.resolve();
      }
    };
    const hostCategoryForm = reactive({
      labelCol: {span: 6},
      wrapperCol: {span: 14},
      other: '',
      form: {
        name: ''
      },
      rules: {
        name: [{
          required: true,
          message: '请输入类别名称',
          validator: validateName,
          trigger: 'blur'
        },
          {min: 3, max: 10, message: '长度在3-10位之间', trigger: 'blur'}
        ]
      }
    })
    const layout = {
      labelCol: {
        span: 4,
      },
      wrapperCol: {
        span: 14,
      },
    };

    const handleFinish = values => {
      console.log(values, hostForm);
    };

    const handleFinishFailed = errors => {
      console.log(errors);
    };

    const resetForm = () => {
      formRef.value.resetFields();
    };

    const handleValidate = (...args) => {
      console.log(args);
    };

    const hostFormVisible = ref(false);

    const showHostModal = () => {
      hostFormVisible.value = true;
    };

    const showHostCategoryFormModal = () => {
      // 显示添加主机类别的表单窗口
      HostCategoryFromVisible.value = true
    }
    const hostCategoryFormCancel = () => {
      // 添加主机类别的表单取消
      hostCategoryForm.form.name = ""; // 清空表单内容
      HostCategoryFromVisible.value = false // 关闭对话框
    }

    const onHostCategoryFromSubmit = () => {
      // 添加主机类别的表单提交处理
    }

    const onHostFormSubmit = () => {
      // 添加主机的表单提交处理
    }

    const get_host_list = () => {
      // 获取主机类别列表

    }
    const get_category_list = () => {
      // 获取主机类别列表

    }

    const deleteHost = record => {
      // 删除主机接口
    }

    get_host_list()
    get_category_list()

    return {
      selectHostCategory: ref('yuan'),
      hostForm,
      formRef,
      layout,
      HostCategoryFromVisible,
      handleCategorySelectChange,
      handleFinishFailed,
      handleFinish,
      resetForm,
      handleValidate,
      hostFormVisible,
      showHostModal,
      onHostFormSubmit,
      deleteHost,
      showHostCategoryFormModal,
      hostCategoryForm,
      hostCategoryFormCancel,
      onHostCategoryFromSubmit,
      hostFormColumns: [
        {
          title: '类别',
          dataIndex: 'category_name',
          key: 'category_name'
        },
        {
          title: '主机名称',
          dataIndex: 'name',
          key: 'name',
          sorter: true,
          width: 230

        },
        {
          title: '连接地址',
          dataIndex: 'ip_addr',
          key: 'ip_addr',
          ellipsis: true,
          sorter: true,
          width: 150
        },
        {
          title: '端口',
          dataIndex: 'port',
          key: 'port',
          ellipsis: true
        },
        {
          title: '备注信息',
          dataIndex: 'remark',
          key: 'remark',
          ellipsis: true
        },

        {
          title: '操作',
          key: 'action',
          width: 200,
          dataIndex: "action",
          scopedSlots: {customRender: 'action'}
        }
      ],
      hostList,
      categoryList,

    };
  },
};
</script>

4.1.2、主机类别与主机接口

(1)创建app

cd uric_api/apps/
python ../../manage.py startapp host

(2)注册

INSTALLED_APPS = [
    'host',
]

(3)构建路由

总路由

path('host/', include('host.urls')),

在host应用中创建urls.py文件,写如下内容

from django.urls import path,re_path
from host import views

urlpatterns = [
  	
]

(4)模型models

urci_api/utils/models.py 基础模型类

from django.db import models

# Create your models here.
class BaseModel(models.Model):
    """公共模型"""
    name = models.CharField(max_length=500,default="", null=True, blank=True, verbose_name='名称/标题')
    is_show = models.BooleanField(default=True, verbose_name="是否显示")
    orders = models.IntegerField(default=1, verbose_name="排序")
    is_deleted = models.BooleanField(default=False, verbose_name="是否删除")
    created_time = models.DateTimeField(auto_now_add=True, verbose_name="添加时间")
    updated_time = models.DateTimeField(auto_now=True, verbose_name="修改时间")
    description = models.TextField(null=True, blank=True, default="", verbose_name="描述信息")

    class Meta:
        # 数据库迁移时,设置了bstract = True的model类不会生成数据库表
        abstract = True

    def __str__(self):
        return self.name

host/models.py的主机相关的模型代码

from django.db import models

# Create your models here.
from uric_api.utils.models import BaseModel, models
from users.models import User


class HostCategory(BaseModel):
    """主机类别"""

    class Meta:
        db_table = "host_category"
        verbose_name = "主机类别"
        verbose_name_plural = verbose_name  # 取消提示文字中关于英文复数+s的情况


class Host(BaseModel):
    # 真正在数据库中的字段实际上叫 category_id,而category则代表了关联的哪个分类模型对象
    category = models.ForeignKey('HostCategory', on_delete=models.DO_NOTHING, verbose_name='主机类别', related_name='hc',
                                 null=True, blank=True)
    ip_addr = models.CharField(blank=True, null=True, max_length=500, verbose_name='连接地址')
    port = models.IntegerField(verbose_name='端口')
    username = models.CharField(max_length=50, verbose_name='登录用户')
    users = models.ManyToManyField(User)

    class Meta:
        db_table = "host"
        verbose_name = "主机信息"
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.name + ':' + self.ip_addr

(5)序列化器

host.serializers代码:

from rest_framework import serializers
from . import models

class HostCategoryModelSeiralizer(serializers.ModelSerializer):
   """主机分类的序列化器"""
   class Meta:
       model = models.HostCategory
       fields = ['id', 'name']


class HostModelSerializers(serializers.ModelSerializer):
   """主机信息的序列化器"""
   category_name = serializers.CharField(source='category.name', read_only=True)
   password = serializers.CharField(max_length=32, write_only=True, label="登录密码")

   class Meta:
       model = models.Host
       fields = ['id', 'category', 'category_name', 'name', 'ip_addr', 'port', 'description', 'username', 'password']

   def validate(self, attrs):
       """当用户添加、编辑主机信息会自动执行这个方法"""
       ip_addr = attrs.get('ip_addr')
       port = attrs.get('port')
       username = attrs.get('username')
       password = attrs.get('password')

       # todo 验证主机信息是否正确

       return attrs


   # 添加host记录,如果第一次添加host记录,那么需要我们生成全局的公钥和私钥
   def create(self, validated_data):
       print('接受通过验证以后的数据字典:',validated_data)
       ip_addr = validated_data.get('ip_addr')
       port = validated_data.get('port')
       username = validated_data.get('username')
       password = validated_data.get('password')

       # todo 生成公私钥和管理主机的公私钥

       # 剔除密码字段,保存host记录
       password = validated_data.pop('password')
       instance = models.Host.objects.create(
           **validated_data
       )
       return instance

(6)视图函数:

# Create your views here.
from rest_framework.generics import ListAPIView,CreateAPIView
from rest_framework.viewsets import ModelViewSet
from .models import HostCategory,Host
from .serializers import HostCategoryModelSeiralizer,HostModelSerializers
from rest_framework.permissions import IsAuthenticated

class HostCategoryListAPIView(ListAPIView, CreateAPIView):
    """主机类别"""
    queryset = HostCategory.objects.filter(is_show=True, is_deleted=False).order_by("orders","-id").all()
    serializer_class = HostCategoryModelSeiralizer
    permission_classes = [IsAuthenticated]

class HostModelViewSet(ModelViewSet):
    """主机信息"""
    queryset = Host.objects.all()
    serializer_class = HostModelSerializers
    permission_classes = [IsAuthenticated]

路由,代码:

from django.urls import path, include, re_path
from host import views

urlpatterns = [
    path('', views.HostModelViewSet.as_view({'get': 'list', 'post': 'create'})),
    re_path('(?P<pk>\d+)', views.HostModelViewSet.as_view({'delete': 'destroy'})),
    path('category', views.HostCategoryListAPIView.as_view()),
]

测试主机类别接口:

image-20220622203649289

image-20220622203838704

测试主机接口:

image-20220623125408732

image-20220623125445062

4.1.3、前端对接后端接口

<template>
  <a-row>
    <a-col :span="6">
      <div class="add_host" style="margin: 15px;">

        <a-button @click="showHostModal" type="primary">
          新建
        </a-button>
        <a-button type="primary" style="margin-left: 20px;">
          批量导入
        </a-button>
      </div>
    </a-col>

  </a-row>
  <a-table :dataSource="hostList.data" :columns="hostFormColumns">
    <template #bodyCell="{ column, text, record }">
      <template v-if="column.dataIndex === 'action'">
        <a-popconfirm
            v-if="hostList.data.length"
            title="Sure to delete?"
            @confirm="deleteHost(record)"
        >
          <a>Delete</a>
        </a-popconfirm>
      </template>
    </template>

  </a-table>

  <div>

    <a-modal v-model:visible="hostFormVisible" title="添加主机" @ok="onHostFormSubmit" @cancel="resetForm()" :width="800">
      <a-form
          ref="formRef"
          name="custom-validation"
          :model="hostForm.form"
          :rules="hostForm.rules"
          v-bind="layout"
          @finish="handleFinish"
          @validate="handleValidate"
          @finishFailed="handleFinishFailed"
      >
        <a-form-item label="主机类别" prop="zone" name="category">
          <a-row>
            <a-col :span="12">
              <a-select
                  ref="select"
                  v-model:value="hostForm.form.category"
                  @change="handleCategorySelectChange"

              >
                <a-select-option :value="category.id" v-for="category in categoryList.data" :key="category.id">
                  {{ category.name }}
                </a-select-option>
              </a-select>
            </a-col>

          </a-row>

        </a-form-item>

        <a-form-item has-feedback label="主机名称" name="name">
          <a-input v-model:value="hostForm.form.name" type="text" autocomplete="off"/>
        </a-form-item>


        <a-form-item has-feedback label="连接地址" name="username">

          <a-row>
            <a-col :span="8">
              <a-input placeholder="用户名" addon-before="ssh" v-model:value="hostForm.form.username" type="text"
                       autocomplete="off"/>
            </a-col>
            <a-col :span="8">
              <a-input placeholder="ip地址" addon-before="@" v-model:value="hostForm.form.ip_addr" type="text"
                       autocomplete="off"/>
            </a-col>
            <a-col :span="8">
              <a-input placeholder="端口号" addon-before="-p" v-model:value="hostForm.form.port" type="text"
                       autocomplete="off"/>
            </a-col>
          </a-row>
        </a-form-item>

        <a-form-item has-feedback label="连接密码" name="password">
          <a-input v-model:value="hostForm.form.password" type="password" autocomplete="off"/>
        </a-form-item>

        <a-form-item has-feedback label="备注信息" name="remark">
          <a-textarea placeholder="请输入主机备注信息" v-model:value="hostForm.form.remark" type="text"
                      :auto-size="{ minRows: 3, maxRows: 5 }" autocomplete="off"/>
        </a-form-item>


        <a-form-item :wrapper-col="{ span: 14, offset: 4 }">
          <a-button @click="resetForm">Reset</a-button>
        </a-form-item>
      </a-form>


    </a-modal>
    <a-modal
        :width="600"
        title="新建主机类别"
        :visible="HostCategoryFromVisible"
        @cancel="hostCategoryFormCancel"
    >
      <template #footer>
        <a-button key="back" @click="hostCategoryFormCancel">取消</a-button>
        <a-button key="submit" type="primary" :loading="loading" @click="onHostCategoryFromSubmit">提交</a-button>
      </template>
      <a-form-model ref="hostCategoryRuleForm" v-model:value="hostCategoryForm.form" :rules="hostCategoryForm.rules"
                    :label-col="hostCategoryForm.labelCol" :wrapper-col="hostCategoryForm.wrapperCol">
        <a-form-model-item ref="name" label="类别名称" prop="name">
          <a-row>
            <a-col :span="24">
              <a-input placeholder="请输入主机类别名称" v-model:value="hostCategoryForm.form.name"/>
            </a-col>
          </a-row>
        </a-form-model-item>
      </a-form-model>
    </a-modal>
  </div>

</template>
<script>
import {defineComponent, ref, reactive} from 'vue';
import axios from "axios";
import settings from "@/settings";
import store from "@/store";
import {message} from 'ant-design-vue';

export default {
  setup() {

    const handleChange = value => {
      console.log(`selected ${value}`);
    };

    const handleCategorySelectChange = (value) => {
      // 切换主机类别的回调处理
      console.log(value)
    };


    const formRef = ref();
    const HostCategoryFromVisible = ref(false);
    const hostList = reactive({
      data: [
        {
          'id': 1,
          'category_name': '数据库服务器',
          'name': 'izbp13e05jqwodd605vm3gz',
          'ip_addr': '47.58.131.12',
          'port': 22,
          'remark': ''
        },
        {
          'id': 2,
          'category_name': '数据库服务器',
          'name': 'iZbp1a3jw4l12ho53ivhkkZ',
          'ip_addr': '12.18.125.22',
          'port': 22,
          'remark': ''
        },
        {
          'id': 3,
          'category_name': '缓存服务器',
          'name': 'iZbp1b1xqfqw257gs563k2iZ',
          'ip_addr': '12.19.135.130',
          'port': 22,
          'remark': ''
        },
        {
          'id': 4,
          'category_name': '缓存服务器',
          'name': 'iZbp1b1jw4l01ho53muhkkZ',
          'ip_addr': '47.98.101.89',
          'port': 22,
          'remark': ''
        }
      ]
    })
    const categoryList = reactive({
      data: [
        {'id': 1, 'name': '数据库服务'},
        {'id': 2, 'name': '缓存服务'},
        {'id': 3, 'name': 'web服务'},
        {'id': 4, 'name': '静态文件存储服务'}
      ]
    })

    const hostForm = reactive({
      labelCol: {span: 6},
      wrapperCol: {span: 14},
      other: '',
      form: {
        name: '',
        category: "",
        ip_addr: '',
        username: '',
        port: '',
        remark: '',
        password: ''
      },
      rules: {
        name: [
          {required: true, message: '请输入主机名称', trigger: 'blur'},
          {min: 3, max: 10, message: '长度在3-10位之间', trigger: 'blur'}
        ],
        password: [
          {required: true, message: '请输入连接密码', trigger: 'blur'},
          {min: 3, max: 10, message: '长度在3-10位之间', trigger: 'blur'}
        ],
        category: [
          {required: true, message: '请选择类别', trigger: 'change'}
        ],
        username: [
          {required: true, message: '请输入用户名', trigger: 'blur'},
          {min: 3, max: 10, message: '长度在3-10位', trigger: 'blur'}
        ],
        ip_addr: [
          {required: true, message: '请输入连接地址', trigger: 'blur'},
          {max: 15, message: '长度最大15位', trigger: 'blur'}
        ],
        port: [
          {required: true, message: '请输入端口号', trigger: 'blur'},
          {max: 5, message: '长度最大5位', trigger: 'blur'}
        ]
      }
    });
    let validateName = async (_rule, value) => {
      if (value === '') {
        return Promise.reject('请输入类别名称');
      } else {
        return Promise.resolve();
      }
    };
    const hostCategoryForm = reactive({
      labelCol: {span: 6},
      wrapperCol: {span: 14},
      other: '',
      form: {
        name: ''
      },
      rules: {
        name: [{
          required: true,
          message: '请输入类别名称',
          validator: validateName,
          trigger: 'blur'
        },
          {min: 3, max: 10, message: '长度在3-10位之间', trigger: 'blur'}
        ]
      }
    })
    const layout = {
      labelCol: {
        span: 4,
      },
      wrapperCol: {
        span: 14,
      },
    };

    const handleFinish = values => {
      console.log(values, hostForm);
    };

    const handleFinishFailed = errors => {
      console.log(errors);
    };

    const resetForm = () => {
      formRef.value.resetFields();
    };

    const handleValidate = (...args) => {
      console.log(args);
    };

    const hostFormVisible = ref(false);

    const showHostModal = () => {
      hostFormVisible.value = true;
    };


    const onHostFormSubmit = () => {

      // 将数据提交到后台进行保存,但是先进行连接校验,验证没有问题,再保存

      const formData = new FormData();
      for (let attr in hostForm.form) {
        formData.append(attr, hostForm.form[attr])
      }

      axios.post(`${settings.host}/host/`, formData, {
            headers: {
              Authorization: store.getters.token,
            }
          }
      ).then((response) => {
        console.log("response>>>", response)
        hostList.data.unshift(response.data)

        // 清空
        resetForm()
        hostFormVisible.value = false; // 关闭对话框
        message.success('成功添加主机信息!')

      }).catch((response) => {
        message.error(response.data)
      });
    }

    const deleteHost = record => {
      console.log(record);
      axios.delete(`${settings.host}/host/${record.id}`, {
        headers: {
          Authorization: store.getters.token
        }
      }).then(response => {
        let index = hostList.data.indexOf(record)
        hostList.data.splice(index, 1);

      }).catch(err => {
        message.error('删除主机失败!')
      })


    }
    const showHostCategoryFormModal = () => {
      // 显示添加主机类别的表单窗口
      HostCategoryFromVisible.value = true
    }
    const hostCategoryFormCancel = () => {
      // 添加主机类别的表单取消
      hostCategoryForm.form.name = ""; // 清空表单内容
      HostCategoryFromVisible.value = false // 关闭对话框
    }

    const onHostCategoryFromSubmit = () => {
      // 添加主机类别的表单提交处理
      // 将数据提交到后台进行保存,但是先进行连接校验,验证没有问题,再保存
      axios.post(`${settings.host}/host/category`, hostCategoryForm.form, {
        headers: {
          Authorization: store.getters.token
        }
      }).then(response => {
        message.success({
          content: "创建主机类别成功!",
          duration: 1,
        }).then(() => {
          console.log("response:::", response)
          categoryList.data.unshift(response.data)
          hostCategoryFormCancel()
        })
      })
    }


    const get_host_list = () => {
      // 获取主机类别列表

      axios.get(`${settings.host}/host`, {
        headers: {
          Authorization: store.getters.token
        }
      }).then(response => {
        hostList.data = response.data

      }).catch(err => {
        message.error('无法获取主机类别列表信息!')
      })
    }
    const get_category_list = () => {
      // 获取主机类别列表
      axios.get(`${settings.host}/host/category`, {
        headers: {
          Authorization: store.getters.token
        }
      }).then(response => {
        categoryList.data = response.data
      }).catch(err => {
        message.error('无法获取主机类别列表信息!')
      })
    }
    // 获取主机列表
    get_host_list()
    get_category_list()

    return {
      selectHostCategory: ref('yuan'),
      hostForm,
      formRef,
      layout,
      HostCategoryFromVisible,
      handleCategorySelectChange,
      handleFinishFailed,
      handleFinish,
      resetForm,
      handleValidate,
      hostFormVisible,
      showHostModal,
      onHostFormSubmit,
      deleteHost,
      showHostCategoryFormModal,
      hostCategoryForm,
      hostCategoryFormCancel,
      onHostCategoryFromSubmit,
      hostFormColumns: [
        {
          title: '类别',
          dataIndex: 'category_name',
          key: 'category_name'
        },
        {
          title: '主机名称',
          dataIndex: 'name',
          key: 'name',
          sorter: true,
          width: 230

        },
        {
          title: '连接地址',
          dataIndex: 'ip_addr',
          key: 'ip_addr',
          ellipsis: true,
          sorter: true,
          width: 150
        },
        {
          title: '端口',
          dataIndex: 'port',
          key: 'port',
          ellipsis: true
        },
        {
          title: '备注信息',
          dataIndex: 'remark',
          key: 'remark',
          ellipsis: true
        },

        {
          title: '操作',
          key: 'action',
          width: 200,
          dataIndex: "action",
          scopedSlots: {customRender: 'action'}
        }
      ],
      hostList,
      categoryList,

    };
  },
};
</script>

image-20220623184711719

image-20220623184853799

4.1.4、ssh与paramiko模块

(1)ssh命令

ssh命令是openssh套件中的客户端连接工具,可以给予ssh加密协议实现安全的远程登录服务器,实现对服务器的远程管理。

简单说,SSH是一种网络协议,用于计算机之间的加密登录。如果一个用户从本地计算机,使用SSH协议登录另一台远程计算机,我们就可以认为,这种登录是安全的,即使被中途截获,密码也不会泄露。最早的时候,互联网通信都是明文通信,一旦被截获,内容就暴露无疑。1995年,芬兰学者Tatu Ylonen设计了SSH协议,将登录信息全部加密,成为互联网安全的一个基本解决方案,迅速在全世界获得推广,目前已经成为Linux系统的标准配置。

SSH(远程连接工具)连接原理:ssh服务是一个守护进程(demon),系统后台监听客户端的连接,ssh服务端的进程名为sshd,负责实时监听客户端的请求(IP 22端口),包括公共秘钥等交换等信息。

ssh服务端由2部分组成: openssh(提供ssh服务) openssl(提供加密的程序)

  1. 检查主机上有没有安装SSH服务,使用命令:ssh 若提示命令未找到,则需要安装ssh服务;步骤如下:输入sudo apt-get update命令以实现更新Ubuntu系统–>输入sudo apt-get install openssh-server命令以安装ssh 若输出ssh命令的使用说明,则代表已经安装了。
  2. 检查主机上有没有启动SSH服务,使用命令:service –status-all | grep ssh 若服务已经启动的话,可以看到[+] ssh 若服务还没启动的话,可以看到[-] ssh
  3. 启动ssh服务,使用命令sudo service sshd start

SSH远程登录之口令登录

ssh 用户名@IP地址 -p 端口号

SSH的默认端口是22

image-20220413161739973

image-20220413162104970

(2)paramiko模块

Paramiko是SSHv2协议的Python(2.7,3.4+)实现,同时提供了客户端和服务器功能。尽管Paramiko利用Python C扩展进行了低级加密(Cryptography),但它本身是围绕SSH网络概念的纯Python接口,通过paramiko我们可以完成远程主机连接,指令执行、上传下载文件等操作。下面学习具体用法

官方网址: http://www.paramiko.org/

详细api接口文档:http://docs.paramiko.org/en/stable/

  • SSHClient的作用类似于Linux的ssh命令,是对SSH会话的封装,该类封装了传输(Transport),通道(Channel)及SFTPClient建立的方法(open_sftp),通常用于执行远程命令。
  • SFTPClient的作用类似与Linux的sftp命令,是对SFTP客户端的封装,用以实现远程文件操作,如文件上传、下载、修改文件权限等操作。

ssh连接并执行指令,我们使用paramiko,paramiko依赖于pycrypto模块,所以我们先安装pycrypto

pip install pycrypto
pip install paramiko

Paramiko中的几个概念:

  • Client:ssh客户端短连接模式
  • Transport:可以建立ssh会话长连接模式
    • Channel:是一种类Socket,一种安全的SSH传输通道;
    • Transport:是一种加密的会话,使用时会同步创建了一个加密的Tunnels(通道),这个Tunnels叫做Channel;需要open_session来完成长连接对话。
    • Session:是client与Server保持连接的对象,用connect()/start_client()/start_server()开始会话。

示例1: client模式,直接执行指令

import paramiko
import traceback
from paramiko.ssh_exception import AuthenticationException

if __name__ == '__main__':
    # 通过parammiko创建一个ssh短连接客户端实例对象
    ssh = paramiko.SSHClient()
    # 自动在本机第一次连接远程服务器时,记录主机指纹
    ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    try:
        # 1. 直接密码远程连接的方式
        ssh.connect(hostname='', port=22, username='root', password='', timeout=10)
        # 注意,如果你测试某个服务器的连接时,如果你本地已经配置了这个远程服务器的免密登录(公私钥模式),那么就不能测试出密码是否正确了,因为首先会通过公私钥模式登录,不会使用你的密码的。

        # 2. 使用秘钥免密登录的方式
        # pkey = PkeyModel.objects.get(name='').private
        # pkey = RSAKey.from_private_key(StringIO(pkey))
        # ssh.connect(hostname='47.98.130.212', port=22, username='root', pkey=pkey, timeout=10)

        # 连接成功以后,就可以发送操作指令
        # stdin 输入[本机发送给远程主机的信息]
        # stdout 输出[远程主机返回给本机的信息]
        # stderr 错误
        stdin, stdout, stderr = ssh.exec_command('ls -la')
        # 读取stdout对象中返回的内容,返回结果bytes类型数据
        result = stdout.read()
        print( result.decode() )
        # 关闭连接
        ssh.close()
    except AuthenticationException as e:
        print(e.message)
        print(traceback.format_exc())
        print("连接参数有误,请检查连接信息是否正确!~")

示例2: transport模式

import paramiko
import traceback
from paramiko.ssh_exception import AuthenticationException

if __name__ == '__main__':
    # 通过parammiko创建一个ssh短连接客户端实例对象
    ssh = paramiko.SSHClient()
    # 自动在本机第一次连接远程服务器时,记录主机指纹
    ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())

    try:
        # 1. 直接密码远程连接的方式
        ssh.connect(hostname='47.98.130.212', port=22, username='root', password='123', timeout=10)
        # 注意,如果你测试某个服务器的连接时,如果你本地已经配置了这个远程服务器的免密登录(公私钥模式),那么就不能测试出密码是否正确了,因为首先会通过公私钥模式登录,不会使用你的密码的。

        # 2. 使用秘钥免密登录的方式
        # pkey = PkeyModel.objects.get(name='xxxxxxx').private
        # pkey = RSAKey.from_private_key(StringIO(pkey))
        # ssh.connect(hostname='47.98.130.212', port=22, username='root', pkey=pkey, timeout=10)

        while True:
            # 保存本次ssh的连接的回话状态
            cli = ssh.get_transport().open_session()
            # 设置回话超时时间
            cli.settimeout(120)
            command = input("请输入您要发送的指令:")
            if command == "exit":
                break
            # 发送指令
            cli.exec_command(command)
            # 接受操作指令以后,远程主机返回的结果
            stdout = cli.makefile("rb", -1)
            # 读取结果并转换编码
            content = stdout.read().decode()
            print(content)
            # 关闭连接
            ssh.close()
    except AuthenticationException as e:
        print(e.message)
        print(traceback.format_exc())

远程执行命令
该命令的输入与输出流为标准输入(stdin)、输出(stdout)、错误(stderr)的Python文件对像

命令执行完毕后,通道将关闭,不能再使用。如果您想执行另一个命令,您必须打开一个新频道。

exec_command(command, bufsize=-1, timeout=None, get_pty=False, environment=None)

参数说明:

command(str类型),执行的命令串;
bufsize(int类型),文件缓冲区大小,默认为-1(不限制)
get_pty( term='vt100' , width=80 , height=24 , width_pixels=0 , height_pixels=0 )

在远程服务器上生成新的交互式shell

invoke_shell(term='vt100', width=80, height=24, width_pixels=0, height_pixels=0, environment=None)

在此频道上请求交互式 shell 会话。如果服务器允许,则通道将直接连接到 shell 的 stdin、stdout 和 stderr。

通常您会在此之前调用get_pyt,在这种情况下,shell 将通过 pty 进行操作,并且通道将连接到 pty 的 stdin 和 stdout。

当shell退出时,通道将被关闭并且不能被重用。如果您想打开另一个 shell,您必须打开一个新频道。

例如:在SSH server端创建一个交互式的shell,且可以按自己的需求配置伪终端,可以在invoke_shell()函数中添加参数配置

chan = ssh.invoke_shell()

chan.send(cmd) #利用send函数发送cmd到SSH server,添加做回车来执行shell命令(cmd中需要有\n命令才能执行)。注意不同的情况,如果执行完telnet命令后,telnet的换行符是\r\n
 chan.recv(bufsize)  #通过recv函数获取回显

一般回显数据较多,需要通过while循环读取回显数据

(3)基于ssh的主机验证功能

创建utils.ssh.py文件,集成ssh相关功能:

from paramiko.client import SSHClient, AutoAddPolicy
from paramiko.rsakey import RSAKey
from paramiko.ssh_exception import AuthenticationException, SSHException
from io import StringIO
from paramiko.ssh_exception import NoValidConnectionsError

class SSHParamiko(object):
    def __init__(self, hostname, port=22, username='root', pkey=None, password=None, connect_timeout=2):
        if pkey is None and password is None:
            raise SSHException('私钥或者密码必须选择传入一个')

        self.client = None

        self.params = {
            'hostname': hostname,
            'port': port,
            'username': username,
            'password': password,
            'pkey': RSAKey.from_private_key(StringIO(pkey)) if isinstance(pkey, str) else pkey,
            'timeout': connect_timeout,
        }

    # 检测连接并获取连接
    def is_validated(self):
        print(f"self.client={self.client}")
        if self.client is not None:
            # 告知当前执行上下文,self.client已经实例化
            raise RuntimeError('已经建立连接了!!!')
        print(f"正在ping通{self.client}")
        if not self.client:
            try:
                # 创建客户端连接对象
                self.client = SSHClient()
                # 在本机第一次连接远程主机时记录指纹信息
                self.client.set_missing_host_key_policy(AutoAddPolicy)
                # 建立连接
                self.client.connect(**self.params)
            except (TimeoutError, NoValidConnectionsError, AuthenticationException) as e:
                return None
        return True

host.serializers.py中添加验证功能:

from rest_framework import serializers
from . import models
from uric_api.utils.ssh import SSHParamiko


class HostCategoryModelSeiralizer(serializers.ModelSerializer):
    """主机分类的序列化器"""

    class Meta:
        model = models.HostCategory
        fields = ['id', 'name']


class HostModelSerializers(serializers.ModelSerializer):
    """主机信息的序列化器"""
    password = serializers.CharField(max_length=32, write_only=True, label="登录密码")
    category_name = serializers.CharField(source="category.name", read_only=True)

    class Meta:
        model = models.Host
        fields = ['id', "category_name", 'category', 'name', 'ip_addr', 'port', 'description', 'username', 'password']

    def validate(self, attrs):
        """当用户添加、编辑主机信息会自动执行这个方法"""
        ip_addr = attrs.get('ip_addr')
        port = attrs.get('port')
        username = attrs.get('username')
        password = attrs.get('password')

        # todo 基于ssh验证主机信息是否正确
        cli = SSHParamiko(ip_addr, port, username, password=str(password))
        if cli.is_validated():  # 测试该链接是否能够使用
            return attrs
        raise serializers.ValidationError("主机认证失败,用户或密码错误!")

    # 添加host记录,如果第一次添加host记录,那么需要我们生成全局的公钥和私钥
    def create(self, validated_data):
        print('接受通过验证以后的数据字典:', validated_data)
        ip_addr = validated_data.get('ip_addr')
        port = validated_data.get('port')
        username = validated_data.get('username')
        password = validated_data.get('password')

        # todo 生成公私钥和管理主机的公私钥

        # 剔除密码字段,保存host记录
        password = validated_data.pop('password')
        instance = models.Host.objects.create(
            **validated_data
        )
        return instance

4.2、批量导入主机数据

4.2.1、客户端发送excel文件

Host.vue

<template>
  <a-row>
    <a-col :span="6">
      <div class="add_host" style="margin: 15px;">

        <a-button @click="showHostModal" type="primary">
          新建
        </a-button>
        <a-button type="primary" @click="showExcelModal" style="margin-left: 20px;">
          批量导入
        </a-button>
      </div>
    </a-col>

  </a-row>
  <a-table :dataSource="hostList.data" :columns="hostFormColumns">
    <template #bodyCell="{ column, text, record }">
      <template v-if="column.dataIndex === 'action'">
        <a-popconfirm
            v-if="hostList.data.length"
            title="Sure to delete?"
            @confirm="deleteHost(record)"
        >
          <a>Delete</a>
        </a-popconfirm>
      </template>
    </template>

  </a-table>


  <a-modal v-model:visible="hostFormVisible" title="添加主机" @ok="onHostFormSubmit" @cancel="resetForm()" :width="800">
    <a-form
        ref="formRef"
        name="custom-validation"
        :model="hostForm.form"
        :rules="hostForm.rules"
        v-bind="layout"
        @finish="handleFinish"
        @validate="handleValidate"
        @finishFailed="handleFinishFailed"
    >
      <a-form-item label="主机类别" prop="zone" name="category">
        <a-row>
          <a-col :span="12">
            <a-select
                ref="select"
                v-model:value="hostForm.form.category"
                @change="handleCategorySelectChange"

            >
              <a-select-option :value="category.id" v-for="category in categoryList.data" :key="category.id">
                {{ category.name }}
              </a-select-option>
            </a-select>
          </a-col>

        </a-row>

      </a-form-item>

      <a-form-item has-feedback label="主机名称" name="name">
        <a-input v-model:value="hostForm.form.name" type="text" autocomplete="off"/>
      </a-form-item>


      <a-form-item has-feedback label="连接地址" name="username">

        <a-row>
          <a-col :span="8">
            <a-input placeholder="用户名" addon-before="ssh" v-model:value="hostForm.form.username" type="text"
                     autocomplete="off"/>
          </a-col>
          <a-col :span="8">
            <a-input placeholder="ip地址" addon-before="@" v-model:value="hostForm.form.ip_addr" type="text"
                     autocomplete="off"/>
          </a-col>
          <a-col :span="8">
            <a-input placeholder="端口号" addon-before="-p" v-model:value="hostForm.form.port" type="text"
                     autocomplete="off"/>
          </a-col>
        </a-row>
      </a-form-item>

      <a-form-item has-feedback label="连接密码" name="password">
        <a-input v-model:value="hostForm.form.password" type="password" autocomplete="off"/>
      </a-form-item>

      <a-form-item has-feedback label="备注信息" name="remark">
        <a-textarea placeholder="请输入主机备注信息" v-model:value="hostForm.form.remark" type="text"
                    :auto-size="{ minRows: 3, maxRows: 5 }" autocomplete="off"/>
      </a-form-item>


      <a-form-item :wrapper-col="{ span: 14, offset: 4 }">
        <a-button @click="resetForm">Reset</a-button>
      </a-form-item>
    </a-form>


  </a-modal>
  <a-modal
      :width="600"
      title="新建主机类别"
      :visible="HostCategoryFromVisible"
      @cancel="hostCategoryFormCancel"
  >
    <template #footer>
      <a-button key="back" @click="hostCategoryFormCancel">取消</a-button>
      <a-button key="submit" type="primary" :loading="loading" @click="onHostCategoryFromSubmit">提交</a-button>
    </template>
    <a-form-model ref="hostCategoryRuleForm" v-model:value="hostCategoryForm.form" :rules="hostCategoryForm.rules"
                  :label-col="hostCategoryForm.labelCol" :wrapper-col="hostCategoryForm.wrapperCol">
      <a-form-model-item ref="name" label="类别名称" prop="name">
        <a-row>
          <a-col :span="24">
            <a-input placeholder="请输入主机类别名称" v-model:value="hostCategoryForm.form.name"/>
          </a-col>
        </a-row>
      </a-form-model-item>
    </a-form-model>
  </a-modal>

  <!-- 批量导入主机 -->
  <div>
    <a-modal v-model:visible="excelVisible" title="导入excel批量创建主机" @ok="onExcelSubmit" @cancel="excelFormCancel"
             :width="800">
      <a-alert type="info" message="导入或输入的密码仅作首次验证使用,并不会存储密码。" banner closable/>
      <br/>

      <p>
        <a-form-item has-feedback label="模板下载" help="请下载使用该模板填充数据后导入">
          <a download="主机导入模板.xls">主机导入模板.xls</a>
        </a-form-item>
      </p>
      <p>
        <a-form-item label="默认密码"
                     help="如果Excel中密码为空则使用该密码">
          <a-input v-model:value="default_password" placeholder="请输入默认主机密码" type="password"/>
        </a-form-item>
      </p>
      <a-form-item label="导入数据">
        <div class="clearfix">
          <a-upload
              :file-list="fileList"
              name="file"
              :before-upload="beforeUpload"
          >
            <a-button>
              <upload-outlined></upload-outlined>
              Click to Upload
            </a-button>
          </a-upload>

        </div>
      </a-form-item>

    </a-modal>
  </div>


</template>
<script>
import {defineComponent, ref, reactive} from 'vue';
import axios from "axios";
import settings from "@/settings";
import store from "@/store";
import {message} from 'ant-design-vue';
import {UploadOutlined} from '@ant-design/icons-vue';

export default {
  components: {
    UploadOutlined,
  },
  setup() {

    const handleCategorySelectChange = (value) => {
      // 切换主机类别的回调处理
      console.log(value)
    };


    const formRef = ref();
    const HostCategoryFromVisible = ref(false);
    const default_password = ref("");
    const hostList = reactive({
      data: []
    })
    const categoryList = reactive({
      data: []
    })

    const hostForm = reactive({
      labelCol: {span: 6},
      wrapperCol: {span: 14},
      other: '',
      form: {
        name: '',
        category: "",
        ip_addr: '',
        username: '',
        port: '',
        remark: '',
        password: ''
      },
      rules: {
        name: [
          {required: true, message: '请输入主机名称', trigger: 'blur'},
          {min: 3, max: 30, message: '长度在3-10位之间', trigger: 'blur'}
        ],
        password: [
          {required: true, message: '请输入连接密码', trigger: 'blur'},
          {min: 3, max: 30, message: '长度在3-10位之间', trigger: 'blur'}
        ],
        category: [
          {required: true, message: '请选择类别', trigger: 'change'}
        ],
        username: [
          {required: true, message: '请输入用户名', trigger: 'blur'},
          {min: 3, max: 30, message: '长度在3-10位', trigger: 'blur'}
        ],
        ip_addr: [
          {required: true, message: '请输入连接地址', trigger: 'blur'},
          {max: 30, message: '长度最大15位', trigger: 'blur'}
        ],
        port: [
          {required: true, message: '请输入端口号', trigger: 'blur'},
          {max: 5, message: '长度最大5位', trigger: 'blur'}
        ]
      }
    });
    let validateName = async (_rule, value) => {
      if (value === '') {
        return Promise.reject('请输入类别名称');
      } else {
        return Promise.resolve();
      }
    };
    const hostCategoryForm = reactive({
      labelCol: {span: 6},
      wrapperCol: {span: 14},
      other: '',
      form: {
        name: ''
      },
      rules: {
        name: [{
          required: true,
          message: '请输入类别名称',
          validator: validateName,
          trigger: 'blur'
        },
          {min: 3, max: 10, message: '长度在3-10位之间', trigger: 'blur'}
        ]
      }
    })
    const layout = {
      labelCol: {
        span: 4,
      },
      wrapperCol: {
        span: 14,
      },
    };

    const handleFinish = values => {
      console.log(values, hostForm);
    };

    const handleFinishFailed = errors => {
      console.log(errors);
    };

    const resetForm = () => {
      formRef.value.resetFields();
    };

    const handleValidate = (...args) => {
      console.log(args);
    };

    const hostFormVisible = ref(false);
    const excelVisible = ref(false);

    const showHostModal = () => {
      hostFormVisible.value = true;
    };


    const onHostFormSubmit = () => {

      // 将数据提交到后台进行保存,但是先进行连接校验,验证没有问题,再保存

      const formData = new FormData();
      for (let attr in hostForm.form) {
        formData.append(attr, hostForm.form[attr])
      }

      axios.post(`${settings.host}/host/`, formData, {
            headers: {
              Authorization: store.getters.token,
            }
          }
      ).then((response) => {
        console.log("response>>>", response)
        hostList.data.unshift(response.data)

        // 清空
        resetForm()
        hostFormVisible.value = false; // 关闭对话框
        message.success('成功添加主机信息!')

      }).catch((err) => {
        message.error('添加主机失败')
      });
    }

    const deleteHost = record => {
      console.log(record);
      axios.delete(`${settings.host}/host/${record.id}`, {
        headers: {
          Authorization: store.getters.token
        }
      }).then(response => {
        let index = hostList.data.indexOf(record)
        hostList.data.splice(index, 1);

      }).catch(err => {
        message.error('删除主机失败!')
      })


    }
    const showHostCategoryFormModal = () => {
      // 显示添加主机类别的表单窗口
      HostCategoryFromVisible.value = true
    }
    const hostCategoryFormCancel = () => {
      // 添加主机类别的表单取消
      hostCategoryForm.form.name = ""; // 清空表单内容
      HostCategoryFromVisible.value = false // 关闭对话框
    }

    const excelFormCancel = () => {
      excelVisible.value = false
    }

    const onHostCategoryFromSubmit = () => {
      // 添加主机类别的表单提交处理
      // 将数据提交到后台进行保存,但是先进行连接校验,验证没有问题,再保存
      axios.post(`${settings.host}/host/category`, hostCategoryForm.form, {
        headers: {
          Authorization: store.getters.token
        }
      }).then(response => {
        message.success({
          content: "创建主机类别成功!",
          duration: 1,
        }).then(() => {
          console.log("response:::", response)
          categoryList.data.unshift(response.data)
          hostCategoryFormCancel()
        })
      })
    }


    const get_host_list = () => {
      // 获取主机类别列表

      axios.get(`${settings.host}/host`, {
        headers: {
          Authorization: store.getters.token
        }
      }).then(response => {
        hostList.data = response.data

      }).catch(err => {
        message.error('无法获取主机类别列表信息!')
      })
    }
    const get_category_list = () => {
      // 获取主机类别列表
      axios.get(`${settings.host}/host/category`, {
        headers: {
          Authorization: store.getters.token
        }
      }).then(response => {
        categoryList.data = response.data
      }).catch(err => {
        message.error('无法获取主机类别列表信息!')
      })
    }
    // 获取主机列表
    get_host_list()
    get_category_list()

    // 上传excel文件
    const showExcelModal = () => {
      // 显示批量上传主机的窗口
      excelVisible.value = true
    }
    const handleChange = info => {
      if (info.file.status !== 'uploading') {
        console.log(info.file, info.fileList);
      }
      if (info.file.status === 'done') {
        message.success(`${info.file.name} file uploaded successfully`);
      } else if (info.file.status === 'error') {
        message.error(`${info.file.name} file upload failed.`);
      }
    };

    const fileList = ref([]);
    const beforeUpload = (file) => {
      // 当用户选择上传文件以后,需要手动把当前文件添加到待上传文件列表this.excel_fileList中
      fileList.value = [...fileList.value, file];
      return false;
    }
    const onExcelSubmit = () => {
      // 将数据提交到后台进行保存,但是先进行连接校验,验证没有问题,再保存
      const formData = new FormData();
      console.log("fileList.value:", fileList.value)
      fileList.value.forEach(file => {
        console.log(">>>", file)
        formData.append('host_excel', file);
      });


      axios.post(`${settings.host}/host/excel_host`, formData, {
            headers: {
              Authorization: store.getters.token,
              'Content-Type': 'multipart/form-data', // 上传文件必须设置请求头中的提交内容格式:multipart/form-data
            }
          }
      ).then((response) => {
        console.log("response:::", response)
        console.log("hostList:::", hostList)
        excelFormCancel()// 关闭对话框
        fileList.value = []
        hostList.data.push(...response.data.data)
        message.success('批量创建主机成功!!')

      }).catch((response) => {
        message.error(response.data.message)
      });
    }
    return {
      beforeUpload,
      onExcelSubmit,
      selectHostCategory: ref('yuan'),
      hostForm,
      formRef,
      layout,
      HostCategoryFromVisible,
      handleCategorySelectChange,
      handleFinishFailed,
      handleFinish,
      resetForm,
      handleValidate,
      hostFormVisible,
      excelVisible,
      showHostModal,
      onHostFormSubmit,
      deleteHost,
      showHostCategoryFormModal,
      hostCategoryForm,
      hostCategoryFormCancel,
      excelFormCancel,
      onHostCategoryFromSubmit,
      showExcelModal,
      default_password,
      fileList,
      headers: {
        authorization: 'authorization-text',
      },
      handleChange,
      hostFormColumns: [
        {
          title: '类别',
          dataIndex: 'category_name',
          key: 'category_name'
        },
        {
          title: '主机名称',
          dataIndex: 'name',
          key: 'name',
          sorter: true,
          width: 230

        },
        {
          title: '连接地址',
          dataIndex: 'ip_addr',
          key: 'ip_addr',
          ellipsis: true,
          sorter: true,
          width: 150
        },
        {
          title: '端口',
          dataIndex: 'port',
          key: 'port',
          ellipsis: true
        },
        {
          title: '备注信息',
          dataIndex: 'remark',
          key: 'remark',
          ellipsis: true
        },

        {
          title: '操作',
          key: 'action',
          width: 200,
          dataIndex: "action",
          scopedSlots: {customRender: 'action'}
        }
      ],
      hostList,
      categoryList,

    };
  },
};
</script>

image-20220624192147465

4.2.2、基于excel批量创建主机

host.urls.py

path('excel_host', views.ExcelHostView.as_view()),

host.views.py

from openpyxl import load_workbook


def read_host_excel_data(io_data, default_password=''):
    """
    从excel中读取主机列表信息
    io_data: 主机列表的字节流
    default_password: 主机的默认登录密码
    """

    # 加载某一个excel文件
    wb = load_workbook(io_data)
    # 获取worksheet对象的两种方式
    worksheet = wb.worksheets[1]

    # c1 = worksheet.cell(2, 1)  # 第二行第一列
    # print("c1 data:::", c1.value)

    # 查询出数据库现有的所有分类数据[ID,name]
    # 由于拿到的是分类名称,所以我们要找到对应名称的分类id,才能去数据库里面存储
    category_list = HostCategory.objects.values_list('id', 'name')

    # 主机列表
    host_info_list = []
    for row in worksheet.iter_rows(2):
        if not row[0].value: continue
        one_row_dict = {}  # 单个主机信息字典

        for category_data in category_list:
            # print(category_data[1],type(category_data[1]),category,type(category))
            if category_data[1].strip() == row[0].value:
                one_row_dict['category'] = category_data[0]
                break

        one_row_dict["name"] = row[1].value  # 主机别名
        one_row_dict['ip_addr'] = row[2].value  # 主机地址
        one_row_dict['port'] = row[3].value  # 主机端口号
        one_row_dict['username'] = row[4].value  # 登录账户名

        excel_pwd = row[5].value
        try:
            pwd = str(excel_pwd)  # 这样强转容易报错,最好捕获一下异常,并记录单元格位置,给用户保存信息时,可以提示用户哪个单元格的数据有问题
        except Exception as e:
            pwd = default_password

        if not pwd.strip():
            pwd = default_password

        one_row_dict['password'] = pwd
        one_row_dict['description'] = row[6].value
        print("one_row_dict", one_row_dict)

        host_info_list.append(one_row_dict)

    # 校验主机数据
    # 将做好的主机信息字典数据通过我们添加主机时的序列化器进行校验
    res_data = {}  # 存放上传成功之后需要返回的主机数据和某些错误信息数据
    serializers_host_res_data = []
    res_error_data = []
    for k, host_data in enumerate(host_info_list):
        # 反序列化校验每一个主机信息
        serailizer = HostModelSerializers(data=host_data)
        if serailizer.is_valid():
            new_host_obj = serailizer.save()
            serializers_host_res_data.append(new_host_obj)
        else:
            # 报错,并且错误信息中应该体验错误的数据位置
            res_error_data.append({'error': f'该{k + 1}行数据有误,其他没有问题的数据,已经添加成功了,请求失败数据改完之后,重新上传这个错误数据,成功的数据不需要上传了'})

    # # 再次调用序列化器进行数据的序列化,返回给客户端
    serializer = HostModelSerializers(instance=serializers_host_res_data, many=True)
    res_data['data'] = serializer.data
    res_data['error'] = res_error_data

    return res_data


class ExcelHostView(APIView):
    def post(self, request):
        """批量导入主机列表"""
        # 接受客户端上传的数据
        host_excel = request.FILES.get("host_excel")
        default_password = request.data.get("default_password")
        print("host_excel:::", host_excel, type(host_excel))
        print("default_password:::", default_password, type(default_password))

        # # 把上传文件全部写入到字节流,就不需要保存到服务端硬盘了。
        io_data = BytesIO()
        for line in host_excel:
            io_data.write(line)
        #
        data = read_host_excel_data(io_data, default_password)
        #
        return Response(data)

4.3、consoles功能

4.3.1、免密登陆

前面我们已经完成了主机列表展示和添加了,我们实现添加主机时,新添加主机需要填写连接主机的用户名和密码,我们接下来就需要通过密码来进行主机连接验证,如果用户名和密码没有问题,那么添加到主机列表中,以后对这个主机的操作都能够完成免密操作,所以我们有两件事情要做:

1、在添加主机信息时连接一次远程主机

2、配置公私钥进行免密登录

image-20210116154703129

host.models.py

from django.db import models

# Create your models here.
from uric_api.utils.models import BaseModel, models
from users.models import User
from uric_api.utils.ssh import SSHParamiko


class HostCategory(BaseModel):
    """主机类别"""

    class Meta:
        db_table = "host_category"
        verbose_name = "主机类别"
        verbose_name_plural = verbose_name  # 取消提示文字中关于英文复数+s的情况


class Host(BaseModel):
    # 真正在数据库中的字段实际上叫 category_id,而category则代表了关联的哪个分类模型对象
    category = models.ForeignKey('HostCategory', on_delete=models.DO_NOTHING, verbose_name='主机类别', related_name='hc',
                                 null=True, blank=True)
    ip_addr = models.CharField(blank=True, null=True, max_length=500, verbose_name='连接地址')
    port = models.IntegerField(verbose_name='端口')
    username = models.CharField(max_length=50, verbose_name='登录用户')
    users = models.ManyToManyField(User)

    class Meta:
        db_table = "host"
        verbose_name = "主机信息"
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.name + ':' + self.ip_addr

# 全局密钥和共钥,所有用户都使用这个一对
class PkeyModel(BaseModel):
    name = models.CharField(max_length=500, unique=True)  # 名称
    private = models.TextField(verbose_name="私钥")
    public = models.TextField(verbose_name="公钥")

    def __repr__(self):
        return f'<Pkey {self.name}>'

数据库迁移。

对PkeyModel的操作,我们简单封装2个方法用于存储和读取公私钥,方便后续操作

创建uric/utils/key.py

from functools import lru_cache
from host.models import PkeyModel


class PkeyManager(object):

    keys = ('public_key', 'private_key',)
    # 由于我们可能经常会执行这个get操作,所以我们使用了django的缓存机制,对方法的结果进行缓存,
    # 第二次调用 get()方法 时,并没有真正执行方法,而是直接返回缓存的结果,
    # 参数maxsize为最多缓存的次数,如果为None,则无限制,设置为2n时,性能最佳
    @classmethod
    @lru_cache(maxsize=64)
    def get(cls, name):
        info = PkeyModel.objects.filter(name=name).first()
        if not info:
            raise KeyError(f'没有这个 {name!r} 秘钥对')

        # 以元组格式,返回公私钥
        return (info.private, info.public)

    @classmethod
    def set(cls, name, private_key, public_key, description=None):
        """保存公私钥"""
        PkeyModel.objects.update_or_create(name=name, defaults={
            'private': private_key,
            'public': public_key,
            'description': description
        })

utils.ssh

from paramiko.client import SSHClient, AutoAddPolicy
from paramiko.rsakey import RSAKey
from paramiko.ssh_exception import AuthenticationException, SSHException
from io import StringIO
from paramiko.ssh_exception import NoValidConnectionsError

class SSHParamiko(object):
    def __init__(self, hostname, port=22, username='root', pkey=None, password=None, connect_timeout=3):
        if pkey is None and password is None:
            raise SSHException('私钥或者密码必须选择传入一个')

        self.client = None

        self.params = {
            'hostname': hostname,
            'port': port,
            'username': username,
            'password': password,
            'pkey': RSAKey.from_private_key(StringIO(pkey)) if isinstance(pkey, str) else pkey,
            'timeout': connect_timeout,
        }

    # 检测连接并获取连接
    def get_connected_client(self):
        if self.client is not None:
            # 告知当前执行上下文,self.client已经实例化
            raise RuntimeError('已经建立连接了!!!')

        if not self.client:
            try:
                # 创建客户端连接对象
                self.client = SSHClient()
                # 在本机第一次连接远程主机时记录指纹信息
                self.client.set_missing_host_key_policy(AutoAddPolicy)
                # 建立连接: 口令密码或者密钥
                self.client.connect(**self.params)
            except (TimeoutError, NoValidConnectionsError, AuthenticationException) as e:
                return None

        return self.client

    @staticmethod
    def gen_key():
        # 生成公私钥键值对
        iodata = StringIO()
        key = RSAKey.generate(2048)  # 生成长度为2024的秘钥对
        key.write_private_key(iodata)
        # 返回值是一个元祖,两个成员分别是私钥和公钥
        return iodata.getvalue(), 'ssh-rsa ' + key.get_base64()

    # 将公钥上传到对应主机
    def upload_key(self, public_key):
        print("self.client:::", self.client)
        # 700 是文档拥有可读可写可执行,同一组用户或者其他用户都不具有操作权限
        # 600 是文件拥有者可读可写,不可执行,同一组用户或者其他用户都不具有操作权限
        cmd = f'mkdir -p -m 700 ~/.ssh && \
            echo {public_key!r} >> ~/.ssh/authorized_keys && \
            chmod 600 ~/.ssh/authorized_keys'
        code, out = self.execute_cmd(cmd)
        print("out", out)
        if code != 0:
            raise Exception(f'添加公钥失败: {out}')

    def execute_cmd(self, cmd, timeout=1800, environment=None):
        # 设置执行指令过程,一旦遇到错误/异常,则直接退出操作,不再继续执行。
        cmd = 'set -e\n' + cmd
        channel = self.client.get_transport().open_session()
        channel.settimeout(timeout)
        channel.set_combine_stderr(True)  # 正确和错误输出都在一个管道对象里面输出出来
        channel.exec_command(cmd)
        try:
            out_data = channel.makefile("rb", -1).read().decode()
        except UnicodeDecodeError:
            out_data = channel.makefile("rb", -1).read().decode("GBK")

        return channel.recv_exit_status(), out_data

序列化器:host.serializers

from rest_framework import serializers
from . import models
from uric_api.utils.ssh import SSHParamiko
from uric_api.utils.key import PkeyManager
from django.conf import settings


class HostCategoryModelSeiralizer(serializers.ModelSerializer):
    """主机分类的序列化器"""

    class Meta:
        model = models.HostCategory
        fields = ['id', 'name']


class HostModelSerializers(serializers.ModelSerializer):
    """主机信息的序列化器"""
    password = serializers.CharField(max_length=32, write_only=True, label="登录密码")
    category_name = serializers.CharField(source="category.name", read_only=True)

    class Meta:
        model = models.Host
        fields = ['id', "category_name", 'category', 'name', 'ip_addr', 'port', 'description', 'username', 'password']

    def get_public_key(self):
        # todo 生成公私钥和管理主机的公私钥
        # 生成公私钥和管理主机的公私钥
        # 创建公私钥之前,我们先看看之前是否已经创建过公私钥了
        try:
            # 尝试从数据库中提取公私钥
            private_key, public_key = PkeyManager.get(settings.DEFAULT_KEY_NAME)
        except KeyError as e:
            # 没有公私钥存储到数据库中,则生成公私钥
            private_key, public_key = self.ssh.gen_key()
            # 将公钥和私钥保存到数据库中
            PkeyManager.set(settings.DEFAULT_KEY_NAME, private_key, public_key, 'ssh全局秘钥对')
        return public_key

    def validate(self, attrs):
        """当用户添加、编辑主机信息会自动执行这个方法"""
        ip_addr = attrs.get('ip_addr')
        port = attrs.get('port')
        username = attrs.get('username')
        password = attrs.get('password')

        # todo 基于ssh验证主机信息是否正确
        self.ssh = SSHParamiko(ip_addr, port, username, password=str(password))
        self.client = self.ssh.get_connected_client()
        if self.client:  # 测试该链接是否能够使用
            public_key = self.get_public_key()
            # 上传公钥到服务器中
            print("public_key", public_key)
            try:
                self.ssh.upload_key(public_key)
            except Exception as e:
                raise serializers.ValidationError('添加远程主机失败,请检查输入的主机信息!')

            return attrs
        raise serializers.ValidationError("主机认证信息错误!")

    # 添加host记录,如果第一次添加host记录,那么需要我们生成全局的公钥和私钥
    def create(self, validated_data):
        print('接受通过验证以后的数据字典:', validated_data)
        ip_addr = validated_data.get('ip_addr')
        port = validated_data.get('port')
        username = validated_data.get('username')
        password = validated_data.get('password')

        # 剔除密码字段,保存host记录
        password = validated_data.pop('password')
        instance = models.Host.objects.create(
            **validated_data
        )
        return instance

settings.dev配置:

DEFAULT_KEY_NAME = "global"
  1. from django.conf import settings

测试:

ssh root@47.112.179.213
vim ~/.ssh/authorized_keys 

image-20220701142255065

远程服务器的authorized_keys 目前为空。

此时Pkeymodel没有记录,添加主机成功后Pkeymodel中生成记录,远程服务器的authorized_keys中也有了记录。

接下来测试是否可以免密登陆

import paramiko
import traceback
from paramiko.ssh_exception import AuthenticationException
from paramiko.rsakey import RSAKey
from io import StringIO

if __name__ == '__main__':
    # 通过parammiko创建一个ssh短连接客户端实例对象
    ssh = paramiko.SSHClient()
    # 自动在本机第一次连接远程服务器时,记录主机指纹
    ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    try:
        # 1. 直接密码远程连接的方式
        # ssh.connect(hostname='47.112.179.213', port=22, username='root', password='Bazinga$yuan', timeout=10)
        # 注意,如果你测试某个服务器的连接时,如果你本地已经配置了这个远程服务器的免密登录(公私钥模式),那么就不能测试出密码是否正确了,因为首先会通过公私钥模式登录,不会使用你的密码的。
        # 2. 使用秘钥免密登录的方式
        # pkey = PkeyModel.objects.get(name='').private
        private_key = ''''''
        pkey = RSAKey.from_private_key(StringIO(private_key))
        ssh.connect(hostname='47.112.179.213', port=22, username='root', pkey=pkey, timeout=10)

        # 连接成功以后,就可以发送操作指令
        # stdin 输入[本机发送给远程主机的信息]
        # stdout 输出[远程主机返回给本机的信息]
        # stderr 错误
        stdin, stdout, stderr = ssh.exec_command('ls -la')
        # 读取stdout对象中返回的内容,返回结果bytes类型数据
        result = stdout.read()
        print(result.decode())
        # 关闭连接
        ssh.close()
    except AuthenticationException as e:
        print(e.message)
        print(traceback.format_exc())
        print("连接参数有误,请检查连接信息是否正确!~")

4.3.2、websocket与django-channels

(1)websocket协议

http请求的特点:

基于TCP

基于请求响应

短链接

无状态保存

WebSocket是一种在单个TCP连接上进行全双工通讯的协议。WebSocket允许服务端主动向客户端推送数据。在WebSocket协议中,客户端浏览器和服务器只需要完成一次握手就可以创建持久性的连接,并在浏览器和服务器之间进行双向的数据传输。

ws

在一个HTTP访问周期里,如果要执行一个长时间任务,为了避免浏览器等待,后台必须使用异步动作。与此同时也要满足实时需求,用户提交了任务后可以随时去访问任务详情页面,在这里用户能够实时地看到任务的执行进度。

针对异步任务处理,我们使用了Celery把任务放到后台执行。Celery 是一个基于python开发的分布式异步消息任务队列,通过它可以轻松的实现任务的异步处理,Celery在处理一个任务的时候,会把这个任务的进度记录在数据库中。

实现任务的后台执行后,下一步就要解决实时地更新进度信息到网页的问题。从上一步可以知道,数据库中已经存在了任务的进度信息,网页直接访问数据库就可以拿到数据。但是数据库中的进度信息更新的速率不固定,如果使用间隔时间比较短的ajax轮询来访问数据库,会产生很多无用请求,造成资源浪费。综合考虑,我们决定使用WebSocket来实现推送任务进度信息的功能。网站是使用Django搭建的,原生的MTV(模型-模板-视图)设计模式只支持Http请求。幸好Django开发团队在过去的几年里也看到实时网络应用的蓬勃发展,发布了Channels,以插件的形式为Django带来了实时能力。下面两张图展示了原生Django与集成Channels的Django。

img

原生Django

Django本身不支持WebSocket,但可以通过集成Channels框架来实现WebSocket

Channels是针对Django项目的一个增强框架,可以使Django不仅支持HTTP协议,还能支持WebSocket,MQTT等多种协议,同时Channels还整合了Django的auth以及session系统方便进行用户管理及认证。

img

集成Channels的Django

对比两张图可以看出,Channels为Django带来了一些新特性,最明显的就是添加了对WebSocket的支持。Channels最重要的部分也可以看做是任务队列,消息被生产者推到通道,然后传递给监听通道的消费者之一。它与传统的任务队列的主要的区别在于Channels通过网络工作,使生产者和消费者透明地运行在多台机器上,这个网络层就叫做channel layer。Channels推荐使用redis作为它的channel layer backend,但也可以使用其它类型的工具,例如rabbitmq、内存或者IPC。关于Channels的一些基本概念,推荐阅读官方文档。

WebSocket的请求头中重要的字段:

Connection和Upgrade:表示客户端发起的WebSocket请求

Sec-WebSocket-Version:客户端所使用的WebSocket协议版本号,服务端会确认是否支持该版本号

Sec-WebSocket-Key:一个Base64编码值,由浏览器随机生成,用于升级request WebSocket的响应头中重要的字段

HTTP/1.1 101 Switching Protocols:切换协议,WebSocket协议通过HTTP协议来建立运输层的TCP连接

Connection和Upgrade:表示服务端发起的WebSocket响应
Sec-WebSocket-Accept:表示服务器接受了客户端的请求,由Sec-WebSocket-Key计算得来

WebSocket协议的优点:

支持双向通信,实时性更强
数据格式比较轻量,性能开销小,通信高效
支持扩展,用户可以扩展协议或者实现自定义的子协议(比如支持自定义压缩算法等)

WebSocket协议的缺点:

少部分浏览器不支持,浏览器支持的程度与方式有区别
长连接对后端处理业务的代码稳定性要求更高,后端推送功能相对复杂
成熟的HTTP生态下有大量的组件可以复用,WebSocket较少

WebSocket的应用场景:

即时聊天通信,网站消息通知
在线协同编辑,如腾讯文档
多玩家在线游戏,视频弹幕,股票基金实施报价

(2)channels语法

img

继承WebSocketConsumer的连接。

AuthMiddlewareStack:用于WebSocket认证,继承了Cookie Middleware,SessionMiddleware,SessionMiddleware。django的channels封装了django的auth模块,使用这个配置我们就可以在consumer中通过下边的代码获取到用户的信息

def connect(self):
    self.user = self.scope["user"]

self.scope类似于django中的request,包含了请求的type、path、header、cookie、session、user等等有用的信息

(3)websocket案例

配置和使用

channels==2.1.3

channels-redis==2.3.0

注意版本号

启动 Redis 服务默认使用 6379 端口,Django 将使用该端口连接 Redis 服务。

更新项目配置文件 settings.py 中的 INSTALLED_APPS 项

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'app01.apps.App01Config',
    'channels',
]

ASGI_APPLICATION = "chat.routing.application"
# WebSocket
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            "hosts": [('127.0.0.1', 6379)],
        },
    },
}

wsgi.py 同级目录新增文件 routing.py,其作用类型与 urls.py ,用于分发webscoket请求:

from django.urls import path
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from table.consumers import TableConsumer

application = ProtocolTypeRouter({
    # Empty for now (http->django views is added by default)
    'websocket': AuthMiddlewareStack(
        URLRouter([
            path('ws/table/<slug:table_id>/', TableConsumer),
        ])
    ),
})

routing.py路由文件跟djangourl.py功能类似,语法也一样,意思就是访问ws/table/都交给TableConsumer处理。

新增 app 名为 table,在 table 目录下新增 consumers.py:

from channels.generic.websocket import AsyncJsonWebsocketConsumer


class RoomConsumer(AsyncJsonWebsocketConsumer):
    room_id = None

    async def connect(self):
        self.room_id = 'room_{}'.format(self.scope['url_route']['kwargs']['room_id'])
        print("room_id", self.room_id)
        print("self.channel_name", self.channel_name)
        # # Join room group
        print(f"将{self.channel_name}客户端对象添加到组group{self.room_id}中")
        await self.channel_layer.group_add(self.room_id, self.channel_name)
        await self.accept()

    async def disconnect(self, close_code):
        # # Leave room group
        await self.channel_layer.group_discard(self.room_id, self.channel_name)

    # Receive message from WebSocket
    async def receive_json(self, content, **kwargs):
        print("::::", content, type(content))
        # # Send message to room group
        self.from_client = str(self.scope["client"])
        await self.channel_layer.group_send(self.room_id,
                                            {'type': 'message', 'msg': content, "from_client": self.from_client})

    # Receive message from room group
    async def message(self, event):
        message = event['msg']
        # print("self.scope", self.scope)
        # Send message to WebSocket
        print(":::", event["from_client"] + ":" + message)
        await self.send_json(event["from_client"] + ">>>" + message)

TableConsumer类中的函数依次用于处理连接、断开连接、接收消息和处理对应类型的消息,其中channel_layer.group_send(self.table, {'type': 'message', 'message': content})方法,self.table 参数为当前组的组id, {'type': 'message', 'message': content} 部分分为两部分,type 用于指定该消息的类型,根据消息类型调用不同的函数去处理消息,而 message 内为消息主体。

table 目录下的 views.py 中新增函数:

from django.shortcuts import render


def table(request, table_id):
    return render(request, 'table.html', {
        'room_name_json': table_id
    })

from django.contrib import admin
from django.urls import path, re_path
from table.views import table

urlpatterns = [
    path('admin/', admin.site.urls),
    re_path('table/(\d+)', table),
]

前端实现WebSocket:

WebSocket对象一个支持四个消息:onopen,onmessage,oncluse和onerror,我们这里用了两个onmessage和onclose

onopen: 当浏览器和websocket服务端连接成功后会触发onopen消息

onerror: 如果连接失败,或者发送、接收数据失败,或者数据处理出错都会触发onerror消息

onmessage: 当浏览器接收到websocket服务器发送过来的数据时,就会触发onmessage消息,参数e包含了服务端发送过来的数据

onclose: 当浏览器接收到websocket服务器发送过来的关闭连接请求时,会触发onclose消息载请注明出处。

tabletemplates\table 目录下新增 table.html:

<!-- chat/templates/chat/room.html -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>Chat Room</title>
</head>
<body>
<textarea id="chat-log" cols="100" rows="20"></textarea><br/>
<input id="chat-message-input" type="text" size="100"/><br/>
<input id="chat-message-submit" type="button" value="Send"/>
</body>
<script>
    var roomName = {{ room_name_json }};

    var chatSocket = new WebSocket('ws://' + window.location.host + '/ws/table/' + roomName + '/');

    chatSocket.onmessage = function (e) {
        var data = JSON.parse(e.data);
        document.querySelector('#chat-log').value += (JSON.stringify(data).slice(1,-1) + '\n');
    };

    chatSocket.onclose = function (e) {
        console.error('Chat socket closed unexpectedly');
    };

    document.querySelector('#chat-message-input').focus();
    document.querySelector('#chat-message-input').onkeyup = function (e) {
        if (e.keyCode === 13) {  // enter, return
            document.querySelector('#chat-message-submit').click();
        }
    };

    document.querySelector('#chat-message-submit').onclick = function (e) {
        var messageInputDom = document.querySelector('#chat-message-input');
        var message = messageInputDom.value;
        chatSocket.send(JSON.stringify(message));

        messageInputDom.value = '';
    };
</script>
</html>

4.3.3、consoles功能

(1)前端实现

前端的终端效果我们使用xterm.js来完成。

Xterm.js的安装和使用:vue中使用xterm,要在客户端项目根目录下进行安装,

npm install xterm 

xterm.js初始化

在main.js文件中加上如下内容

import 'xterm/css/xterm.css'
import 'xterm/lib/xterm'

在Host.vue中,主机列表后面的console对应的地址,实现参数跳转。

 <template v-if="column.dataIndex === 'consoles'">
        <router-link :to="`/uric/console/${record.id}`">Console</router-link>
</template>

设置路由router:

import Console from '../views/Console' 
{
                meta: {
                    title: 'Console',
                    authenticate: true,
                },
                path: 'console/:host_id',
                name: 'Console',
                component: Console
            },

创建Console.vue组件

<template>
  <div class="console">
    <div id="terminal"></div>
  </div>
</template>

<script>
import {Terminal} from 'xterm'

export default {
  name: "Console",
  mounted() {
    this.show_terminal()
  },
  methods: {
    show_terminal() {
      // 初始化terminal窗口
      let term = new Terminal({
        rendererType: "canvas", //渲染类型
        // rows: 40, //行数
        convertEol: true, // 启用时,光标将设置为下一行的开头
        scrollback: 100,   // 终端中的回滚量
        disableStdin: false, //是否应禁用输入。
        cursorStyle: 'underline', //光标样式
        cursorBlink: true, //光标闪烁
        theme: {
          foreground: '#ffffff', //字体
          background: '#060101', //背景色
          cursor: 'help',//设置光标
        }
      });

      // 建立websocket
      let ws = new WebSocket(`ws://api.uric.cn:8000/ws/ssh/${this.$route.params.id}/`);
      let cmd = '';  // 拼接用户输入的命令


      // 监听接收来自服务端响应的数据
      ws.onmessage = function (event) {
        if (!cmd) {
          //所要执行的操作
          term.write(event.data);
        } else {
          console.log(event.data.split('\r\n'))
          cmd = ''
          let res = event.data.replace(event.data.split('\r\n', 1)[0] + "\r\n", '');

          term.write('\r\n' + res)
        }
      }

      term.prompt = () => {
        term.write('\r\n');
        // term.write('\r\n$ ')
      }

      term.onKey(e => {
        console.log(e.key)
        const ev = e.domEvent
        const printable = !ev.altKey && !ev.altGraphKey && !ev.ctrlKey && !ev.metaKey

        if (ev.key === "Enter") {
          // 按下回车键进行指令的发送
          ws.send(cmd);

        } else if (ev.key === "BackSpace") {
          // Do not delete the prompt
          if (term._core.buffer.x > 2) {
            term.write('\b \b')
          }
        } else if (printable) {
          term.write(e.key);
          cmd += e.key
        }
      })
      term.open(document.getElementById('terminal'));

    }
  }
}
</script>

<style scoped>

</style>

image-20220703093603883

top命令

(2) 后端实现

django没有原生支持的websocket模块,所以我们通过dwebsocket或者channels来完成。

安装channels

pip install channels==2.3.1
pip install channels-redis==2.4.1

配置channel

INSTALLED_APPS = [
   ...
   'channels',
]

# 配置channel的通道层
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            "hosts": [('127.0.0.1', 6379)],
        },
    },
}

# 在host应用下面创建一个routing.py文件
ASGI_APPLICATION = 'uric_api.routing.application'

uric_api/uric_api/routing.py文件

from channels.routing import ProtocolTypeRouter, ChannelNameRouter

# ProtocolTypeRouter 根据不同的请求协议,分发到不同的协议处理系统,如果是websocket协议,那么自动找routing.ws_router进行路由转发,如果是channel,那么通过executors.SSHExecutor路由进行转发,如果是http协议,那么还是按照之前的方式进行分发
from host import ws_urls  # 这里类似原来的http编写代码时的路由,只是当时的路由信息,填写在了urls,而接下来,我们要编写websocket的路由,则写在routing,模块下

application = ProtocolTypeRouter({
    'websocket': ws_urls.ws_router
})

host.ws_urls.py

from django.urls import path
from channels.routing import URLRouter
from .consumer import SSHCmdConsumer
# 由于我们可能会对websocket请求进行一些验证或者身份认证,所以我们在consumer应用下面在创建一个middleware文件,里面可以配置一些认证规则
ws_router = URLRouter([
        path('ws/ssh/<int:id>/', SSHCmdConsumer),
    ])

host.consumer.py

# websocket的视图类代码
# channels中所有的webscoetk视图类,都必须直接或间接继承于WebsocketConsumer
from channels.generic.websocket import WebsocketConsumer
from host.models import Host
from threading import Thread
from uric_api.utils.key import PkeyManager
from django.conf import settings
from uric_api.utils.ssh import SSHParamiko

class SSHCmdConsumer(WebsocketConsumer):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.id = self.scope['url_route']['kwargs']['id']
        # websocket通讯的管道对象
        self.ssh_chan = None
        # 这就是基于paramiko连接远程服务器时的ssh操作对象
        self.ssh = None

    def read_response(self):
        while True:
            data = self.ssh_chan.recv(64 * 1024)
            if not data:
                self.close()
                break
            self.send(data.decode())

    # 4 接收客户端发送过来的指令,并发送给主机执行指令
    def receive(self, text_data=None, bytes_data=None):
        data = text_data or bytes_data
        print('receive:', data, type(data))
        if data:
            self.ssh_chan.send(data + '\r\n')

    def disconnect(self, code):
        """websocket断开连接以后,服务端这边也要和远程主机关闭ssh通信"""
        self.ssh_chan.close()
        self.ssh.close()
        print('Connection close')

    # 1 请求来了自动触发父类connect方法,我们继承拓展父类的connect方法,因为我们和客户端建立连接的同时,就可以和客户端想要操作的主机建立一个ssh连接通道。
    def connect(self):
        print('connect连接来啦')
        self.accept()  # 建立websocket连接,进行连接的三次握手
        self.send('Connecting ...\r\n')
        host = Host.objects.filter(pk=self.id).first()
        try:
            private_key, public_key = PkeyManager.get(settings.DEFAULT_KEY_NAME)
            print(private_key, public_key)
            self.ssh = SSHParamiko(host.ip_addr, host.port, host.username, private_key)
            self.client = self.ssh.get_connected_client()
        except Exception as e:
            self.send(f'Exception: {e}\r\n')
            self.close()
            return

        self.ssh_chan = self.client.invoke_shell(
            term='xterm')  # invoke_shell激活shell终端模式,也就是长连接模式,exec_command()函数是将服务器执行完的结果一次性返回给你;invoke_shell()函数类似shell终端,可以将执行结果分批次返回,所以我们接受数据时需要循环的取数据
        self.ssh_chan.transport.set_keepalive(30)  # 连接中没有任何信息时,该连接能够维持30秒
        # 和主机的连接一旦建立,主机就会将连接信息返回给服务端和主机的连接通道中,并且以后我们还要在这个通道中进行指令发送和指令结果的读取,所以我们开启单独的线程,去连接中一直等待和获取指令执行结果的返回数据
        t = Thread(target=self.read_response)
        t.start()

五、批量与定时任务

5.1、批量命令执行

5.1.1、前端实现

image-20220703005121759

Ace-editor编辑器::文档地址: https://github.com/CarterLi/vue3-ace-editor

客户端根目录下,下载安装

npm install vue3-ace-editor

在需要引入编辑器插件的组件中先挂载插件,然后再使用,那么我们现在就需要在MultiExec.vue中注册插件。

MultiExec.vue

<template>
  <div class="multi_exec">
    <div>
      <h3>执行主机:</h3>
      <div>
        <a-tag closable @close="close_host(info_index)" v-for="(info,info_index) in show_host_info" :key="info.id">
          {{ `${info.name}(${info.ip_addr}:${info.port})` }}
        </a-tag>
      </div>
    </div>
    <div style="margin-top: 10px;">
      <a-button @click="showModal" icon="plus">从主机列表中选择</a-button>

      <div>
        <a-modal v-model:visible="MultiExecVisible" title="" @ok="onMultiExecSubmit" @cancel="excelFormCancel"
                 :width="1000">

          <a-row>
            <a-col :span="8">
              <a-form-item label="主机类别:" :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol">
                <a-select style="width: 160px;" placeholder="请选择" v-model="host_form.form.category"
                          @change="has_change_category">
                  <a-select-option :value="value.id" v-for="(value, index) in categorys" :key="value.id">
                    {{ value.name }}
                  </a-select-option>
                </a-select>
              </a-form-item>
            </a-col>
            <a-col :span="8">
              <a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol" label="主机别名:">
                <a-input placeholder="请输入"/>
              </a-form-item>
            </a-col>
            <a-col :span="4">
              <a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol" label="已选:">
                <span style="margin-left: 8px">
                  <template v-if="hasSelected">
                    {{ `${selectedRowKeys.length}` }}
                  </template>
                </span>
              </a-form-item>
            </a-col>
            <a-col :span="4">
              <a-button type="primary" icon="sync" style="margin-top: 3px;" @click="refresh_data">刷新</a-button>
            </a-col>
          </a-row>
          <div>
            <a-table
                :columns="columns"
                :data-source="data"
                :pagination="false"
                :rowKey="record => record.id"
                :row-selection="{ selectedRowKeys: selectedRowKeys, onChange: onSelectChange }"
            ></a-table>
          </div>
        </a-modal>
      </div>

    </div>

    <v-ace-editor
        v-model:value="content"
        @init="editorInit"
        lang="html"
        theme="chrome"
        style="height: 200px"/>
    <div>
      <a-button type="primary" icon="thunderbolt" @click="execute_cmd">开始执行</a-button>
    </div>
  </div>
</template>

<script>

import {VAceEditor} from 'vue3-ace-editor';
import 'ace-builds/src-noconflict/mode-html';
import 'ace-builds/src-noconflict/theme-chrome';
import axios from "axios";
import store from "@/store";
import {message} from 'ant-design-vue';

const formItemLayout = {
  labelCol: {span: 8},
  wrapperCol: {span: 14},
};
const columns = [
  {
    // slots: {title: 'customTitle'},
    scopedSlots: {customRender: 'action'},
  }, {
    title: '类别',
    dataIndex: 'category_name',
    key: 'category_name',
  }, {
    title: '主机名称',
    dataIndex: 'name',
    key: 'name',
  }, {
    title: '连接地址',
    dataIndex: 'ip_addr',
    key: 'ip_addr',
    width: 200,
  }, {
    title: '端口',
    dataIndex: 'port',
    key: 'port',
  }, {
    title: '备注信息',
    dataIndex: 'description',
    key: 'description',
  },
];


export default {
  name: "MultiExec",
  data() {
    return {
      formItemLayout,        // 弹窗的首行表单配置信息
      columns,               // 弹窗的表格的每一列数据的配置信息
      show_host_info: [],    // 显示选中的所有主机内容
      MultiExecVisible: false,        // 是否显示主机列表的弹窗
      host_form: {
        form: {
          category: undefined,// 当前选择的主机分类ID
        }
      },
      data: [],              // 当前显示表格中的主机列表数据
      categorys: [],         // 主机分类列表
      selectedRowKeys: [],   // 已经勾选的主机ID列表
      selected_host_ids: [], // 选中的主机id列表
      content: ""
    }
  },
  // 计算属性
  computed: {
    hasSelected() {
      return this.selectedRowKeys.length > 0;
    },
  },
  created() {
    this.get_host_category_list()
    this.get_host_list()
  },
  methods: {
    showModal() {
      this.MultiExecVisible = true;
    },
    // 选中主机时触发的,selectedRowKeys被选中的主机id列表
    onSelectChange(selectedRowKeys) {
      this.selectedRowKeys = selectedRowKeys;
    },
    onMultiExecSubmit() {
      this.data.forEach((v, k) => {
        if (this.selectedRowKeys.includes(v.id)) { // 判断某元素是否在数组中用includes比较合适,不能用in
          this.show_host_info.push({
            id: v.id,
            name: v.name,
            ip_addr: v.ip_addr,
            port: v.port,
          })
          this.selected_host_ids.push(v.id);
        }
      })
      // 关闭弹窗
      this.MultiExecVisible = false;
    },
    get_host_category_list() {
      // 获取主机类别
      // let token = sessionStorage.token || localStorage.token;
      axios.get(`${this.$settings.host}/host/category`, {
        headers: {
          Authorization: "jwt " + store.getters.token,
        }
      }).then((response) => {
        this.categorys = response.data;
      })
    },
    get_host_list(category = null) {
      // 获取主机列表
      let params = {}
      if (category !== null) {
        params.category = category
      }
      // let token = sessionStorage.token || localStorage.token;
      axios.get(`${this.$settings.host}/host/`, {
        params: params,
        headers: {
          Authorization: "jwt " + store.getters.token,
        }
      }).then((response) => {
        this.data = response.data;
      })
    },
    has_change_category(category) {
      // 切换主机分类时,重新获取主机列表
      this.get_host_list(category)
    },
    refresh_data() {
      // 刷新数据
      this.host_form.form.category = undefined
      this.get_host_list();
    },
    close_host(info_index) {
      // 移除已经勾选的主机信息
      this.show_host_info.splice(info_index, 1);
      let ids_list = this.selected_host_ids.splice(info_index, 1);
      let id_index = this.selectedRowKeys.indexOf(ids_list[0]);
      this.selectedRowKeys.splice(id_index, 1);
    },
    execute_cmd() {
      // let token = sessionStorage.token || localStorage.token;
      axios.post(`${this.$settings.host}/mtask/cmd_exec`, {
        host_ids: this.selected_host_ids,
        cmd: this.content,
      }, {
        headers: {
          Authorization: "jwt " + store.getters.token,
        }
      }).then((res) => {
        console.log(res);
        message.success('批量任务执行成功!')

      }).catch((err) => {
        message.error('批量任务执行失败!')
      })
    }
  },
  components: {
    VAceEditor,
  },
}
</script>

<style>

</style>

路由代码,src/router/index.js,代码:

 {
       path: 'multi_exec',
       name: 'MultiExec',
       component: MultiExec,
 }

5.1.2、后端实现

(1)创建批量任务应用

关于批量任务,我们是一个单独的模块,所以我们也创建一个单独的应用来完成。

python ../../manage.py startapp mtask

配置应用

INSTALLED_APPS = [
    ...
    'mtask',
]

mtask/urls.py

from django.urls import path
from . import views

urlpatterns = [
]

总路由,uric_api.urls,代码:

from django.contrib import admin
from django.urls import path,include

urlpatterns = [
    ...
    path('mtask/', include('mtask.urls')),
]

(2)获取所有主机信息

服务端提供主机列表数据

在原来的主机管理的视图集的基础上,增加一个按分类显示数据即可。

host/views.py

class HostModelViewSet(ModelViewSet):
    serializer_class = HostModelSerializers
    permission_classes = [IsAuthenticated]

    def get_queryset(self):
        # 重写qureyset方法,补充过滤主机列表参数,获取主机列表
        category_id = self.request.query_params.get("category", None)
        queryset = Host.objects
        if category_id is not None:
            queryset = queryset.filter(category_id=category_id)

        return queryset.all()

(3)执行指令操作

后端实现执行指令的api接口

mtask/urls.py

path('cmd_exec', views.CmdExecView.as_view()),

mtask/views.py

from django.shortcuts import render

# Create your views here.

from rest_framework.views import APIView
from host.models import Host
from uric_api.utils.key import PkeyManager
from django.conf import settings
from rest_framework.response import Response
from uric_api.utils.ssh import SSHParamiko

# Create your views here.
class CmdExecView(APIView):
    def post(self, request):
        host_ids = request.data.get('host_ids')
        cmd = request.data.get('cmd')
        print("host_ids", host_ids)
        if host_ids and cmd:
            exec_host_list = Host.objects.filter(id__in=host_ids)
            pkey, _ = PkeyManager.get(settings.DEFAULT_KEY_NAME)  # 获取ssh秘钥
            response_list = []
            for host in exec_host_list:
                ssh = SSHParamiko(host.ip_addr, host.port, host.username, pkey)
                ssh.get_connected_client()
                # ssh 远程执行指令
                res_code, res_data = ssh.execute_cmd(cmd)
                # res_code为0表示ok,不为0说明指令执行有问题
                response_list.append({
                    'host_info': {
                        'id': host.id,
                        'name': host.name,
                        'ip_addr': host.ip_addr,
                        'port': host.port,
                    },
                    'res_code': res_code,
                    'res_data': res_data,
                })
            return Response(response_list)

        else:
            return Response({'error': '没有该主机或者没有输入指令'}, status=400)

5.2、指令模板

5.2.1、前端实现

image-20220703151226691

<template>
  <div class="multi_exec">
    <div>
      <h3>执行主机:</h3>
      <div>
        <a-tag closable @close="close_host(info_index)" v-for="(info,info_index) in show_host_info" :key="info.id">
          {{ `${info.name}(${info.ip_addr}:${info.port})` }}
        </a-tag>
      </div>
    </div>
    <div style="margin-top: 10px;">
      <a-button @click="showModal" icon="plus">从主机列表中选择</a-button>
      <a-button @click="showModal2">从执行模板中选择</a-button>
      <div style="margin: 20px;">
        <a-modal v-model:visible="visible2" title="选择执行模板" @ok="handleOk2" width="960px">
          <div>
            <a-row>
              <a-col :span="10">
                <a-form-item label="模板类别:" :label-col="formItemLayout.labelCol"
                             :wrapper-col="formItemLayout.wrapperCol">
                  <a-select style="width: 160px;" placeholder="请选择" @change="">
                  </a-select>
                </a-form-item>
              </a-col>
              <a-col :span="10">
                <a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol"
                             label="模板名称:">
                  <a-input placeholder="请输入"/>
                </a-form-item>
              </a-col>
              <a-col :span="4">
                <a-button type="primary" icon="sync" style="margin-top: 3px;" @click="">刷新</a-button>
              </a-col>
            </a-row>
          </div>
          <div>
            <a-table :columns="tem_columns" :data-source="tem_data" :rowKey="record => record.id"
                     :row-selection="{ radioselectedRow: radioselectedRow, onChange: onSelectChange2,type: 'radio' }">
            </a-table>
          </div>
        </a-modal>
      </div>
      <div>
        <a-modal v-model:visible="MultiExecVisible" title="" @ok="onMultiExecSubmit" @cancel="excelFormCancel"
                 :width="1000">

          <a-row>
            <a-col :span="8">
              <a-form-item label="主机类别:" :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol">
                <a-select style="width: 160px;" placeholder="请选择" v-model="host_form.form.category"
                          @change="has_change_category">
                  <a-select-option :value="value.id" v-for="(value, index) in categorys" :key="value.id">
                    {{ value.name }}
                  </a-select-option>
                </a-select>
              </a-form-item>
            </a-col>
            <a-col :span="8">
              <a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol" label="主机别名:">
                <a-input placeholder="请输入"/>
              </a-form-item>
            </a-col>
            <a-col :span="4">
              <a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol" label="已选:">
                <span style="margin-left: 8px">
                  <template v-if="hasSelected">
                    {{ `${selectedRowKeys.length}` }}
                  </template>
                </span>
              </a-form-item>
            </a-col>
            <a-col :span="4">
              <a-button type="primary" icon="sync" style="margin-top: 3px;" @click="refresh_data">刷新</a-button>
            </a-col>
          </a-row>
          <div>
            <a-table
                :columns="columns"
                :data-source="data"
                :pagination="false"
                :rowKey="record => record.id"
                :row-selection="{ selectedRowKeys: selectedRowKeys, onChange: onSelectChange }"
            ></a-table>
          </div>

        </a-modal>
      </div>

    </div>

    <v-ace-editor
        v-model:value="content"
        @init="editorInit"
        lang="html"
        theme="chrome"
        style="height: 200px"/>
    <div>
      <a-button type="primary" icon="thunderbolt" @click="execute_cmd">开始执行</a-button>
    </div>
    <hr>


  </div>


</template>

<script>

import {VAceEditor} from 'vue3-ace-editor';
import 'ace-builds/src-noconflict/mode-html';
import 'ace-builds/src-noconflict/theme-chrome';
import axios from "axios";
import store from "@/store";
import {message} from 'ant-design-vue';

const formItemLayout = {
  labelCol: {span: 8},
  wrapperCol: {span: 14},
};
const columns = [
  {
    // slots: {title: 'customTitle'},
    scopedSlots: {customRender: 'action'},
  }, {
    title: '类别',
    dataIndex: 'category_name',
    key: 'category_name',
  }, {
    title: '主机名称',
    dataIndex: 'name',
    key: 'name',
  }, {
    title: '连接地址',
    dataIndex: 'ip_addr',
    key: 'ip_addr',
    width: 200,
  }, {
    title: '端口',
    dataIndex: 'port',
    key: 'port',
  }, {
    title: '备注信息',
    dataIndex: 'description',
    key: 'description',
  },
];

const tem_columns = [
  {
    title: '模板名称',
    dataIndex: 'name',
    key: 'name',

  },
  {
    title: '模板类型',
    dataIndex: 'category_name',
    key: 'category_name',

  },
  {
    title: '模板内容',
    dataIndex: 'cmd',
    key: 'cmd',
    width: 200,
  },
  {
    title: '描述信息',
    dataIndex: 'description',
    key: 'description',

  },
];


export default {
  name: "MultiExec",
  data() {
    return {

      formItemLayout,        // 弹窗的首行表单配置信息
      columns,               // 弹窗的表格的每一列数据的配置信息
      show_host_info: [],    // 显示选中的所有主机内容
      MultiExecVisible: false,        // 是否显示主机列表的弹窗
      host_form: {
        form: {
          category: undefined,// 当前选择的主机分类ID
        }
      },
      data: [],              // 当前显示表格中的主机列表数据
      categorys: [],         // 主机分类列表
      selectedRowKeys: [],   // 已经勾选的主机ID列表
      selected_host_ids: [], // 选中的主机id列表
      visible2: false,
      template_form: {
        form: {
          category: undefined,
        }
      },
      tem_categorys: [],    // 指令模板分类列表
      tem_data: [],         // 指令模板列表
      radioselectedRow: [], //
      content: "",
      tem_columns,
    }
  }
  ,
// 计算属性
  computed: {
    hasSelected() {
      return this.selectedRowKeys.length > 0;
    }
    ,
  }
  ,
  created() {
    this.get_host_category_list()
    /this.get_host_list()
    // this.get_templates_category_list()
    // this.get_templates_list()
  }
  ,
  methods: {
    showModal() {
      this.MultiExecVisible = true;
    }
    ,
    // 选中主机时触发的,selectedRowKeys被选中的主机id列表
    onSelectChange(selectedRowKeys) {
      this.selectedRowKeys = selectedRowKeys;
    }
    ,
    onMultiExecSubmit() {
      this.data.forEach((v, k) => {
        if (this.selectedRowKeys.includes(v.id)) { // 判断某元素是否在数组中用includes比较合适,不能用in
          this.show_host_info.push({
            id: v.id,
            name: v.name,
            ip_addr: v.ip_addr,
            port: v.port,
          })
          this.selected_host_ids.push(v.id);
        }
      })
      // 关闭弹窗
      this.MultiExecVisible = false;
    }
    ,
    get_host_category_list() {
      // 获取主机类别
      // let token = sessionStorage.token || localStorage.token;
      axios.get(`${this.$settings.host}/host/category`, {
        headers: {
          Authorization: "jwt " + store.getters.token,
        }
      }).then((response) => {
        this.categorys = response.data;
      })
    }
    ,
    get_host_list(category = null) {
      // 获取主机列表
      let params = {}
      if (category !== null) {
        params.category = category
      }
      // let token = sessionStorage.token || localStorage.token;
      axios.get(`${this.$settings.host}/host/`, {
        params: params,
        headers: {
          Authorization: "jwt " + store.getters.token,
        }
      }).then((response) => {
        this.data = response.data;
      })
    }
    ,
    has_change_category(category) {
      // 切换主机分类时,重新获取主机列表
      this.get_host_list(category)
    }
    ,
    refresh_data() {
      // 刷新数据
      this.host_form.form.category = undefined
      this.get_host_list();
    }
    ,
    close_host(info_index) {
      // 移除已经勾选的主机信息
      this.show_host_info.splice(info_index, 1);
      let ids_list = this.selected_host_ids.splice(info_index, 1);
      let id_index = this.selectedRowKeys.indexOf(ids_list[0]);
      this.selectedRowKeys.splice(id_index, 1);
    }
    ,
    execute_cmd() {
      // let token = sessionStorage.token || localStorage.token;
      axios.post(`${this.$settings.host}/mtask/cmd_exec`, {
        host_ids: this.selected_host_ids,
        cmd: this.content,
      }, {
        headers: {
          Authorization: "jwt " + store.getters.token,
        }
      }).then((res) => {
        console.log(res);
        message.success('批量任务执行成功!')

      }).catch((err) => {
        message.error('批量任务执行失败!')
      })
    },


    showModal2() {
      this.visible2 = true;
    },
    handleOk2(e) {
      let tid = this.radioselectedRow[0]; //选中的模板id值
      // 通过模板id值,找到该模板记录中的cmd值,并赋值给content属性
      this.tem_data.forEach((v, k) => {
        if (v.id === tid) {
          this.content = v.cmd;
        }
      })
      this.visible2 = false;
    },
    onSelectChange2(radioselectedRow) {
      // [6, 7, 8, 9]
      console.log('>>>>> ', radioselectedRow);
      this.radioselectedRow = radioselectedRow;
    },
    handleSelectChange2(value) {
      // 切换模板分类
      this.get_templates_list(value)
    },
    refresh_data2() {
      this.get_templates_list();
    },
    get_templates_list(category = null) {
      let params = {}
      if (category !== null) {
        params.category = category
      }
      axios.get(`${this.$settings.host}/mtask/templates`, {
        params: params,
        headers: {
          Authorization: "jwt " + store.getters.token
        }
      }).then(response => {
        this.tem_data = response.data;
      })
    },
    get_templates_category_list() {
      axios.get(`${this.$settings.host}/mtask/templates/categorys`, {
        headers: {
          Authorization: "jwt " + store.getters.token
        }
      })
          .then(response => {
            this.tem_categorys = response.data;
          })
    },
  }
  ,
  components: {
    VAceEditor,
  }
  ,
}
</script>

<style>

</style>

5.2.2、后端实现

mtask/models.py

from django.db import models
from uric_api.utils.models import BaseModel

class CmdTemplateCategory(BaseModel):
    class Meta:
        db_table = "cmd_template_category"
        verbose_name = "模板分类"
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.name

class CmdTemplate(BaseModel):
    category = models.ForeignKey('CmdTemplateCategory', on_delete=models.CASCADE,verbose_name='模板类别')
    cmd = models.TextField(verbose_name='模板内容')

    class Meta:
        db_table = "cmd_template"
        verbose_name = "指令模板"
        verbose_name_plural = verbose_name

数据迁移,终端下在服务端项目根目录下执行

python manage.py makemigrations
python manage.py migrate

添加测试数据,mysql中执行以下SQL语句。

INSERT INTO uric.cmd_template_category (id, name, is_show, orders, is_deleted, created_time, updated_time, description) VALUES (1, '文件操作', 1, 1, 0, '2021-08-07 20:52:55', '2021-08-07 20:52:55', null);
INSERT INTO uric.cmd_template_category (id, name, is_show, orders, is_deleted, created_time, updated_time, description) VALUES (2, '文件夹操作', 1, 1, 0, '2021-08-07 20:52:55', '2021-08-07 20:52:55', null);

INSERT INTO uric.cmd_template (id, name, is_show, orders, is_deleted, created_time, updated_time, description, cmd, category_id) VALUES (1, '列出当前目录下所有文件', 1, 1, 0, '2021-08-07 20:55:45', '2021-08-07 20:55:45', null, 'ls', 2);
INSERT INTO uric.cmd_template (id, name, is_show, orders, is_deleted, created_time, updated_time, description, cmd, category_id) VALUES (2, '创建文件', 1, 1, 0, '2021-08-07 20:55:45', '2021-08-07 20:55:45', null, 'touch index.html', 1);
INSERT INTO uric.cmd_template (id, name, is_show, orders, is_deleted, created_time, updated_time, description, cmd, category_id) VALUES (3, '家目录下创建文件夹', 1, 1, 0, '2021-08-07 20:55:45', '2021-08-07 20:55:45', null, 'cd /home
mkdir uric', 2);

mtask/urls.py

from django.urls import path
from . import views

urlpatterns = [
    path('cmd_exec', views.CmdExecView.as_view()),
    path('templates', views.TemplateView.as_view()),
    path('templates/categorys', views.TemplateCategoryView.as_view()),
]

mtask/views.py

from rest_framework.generics import ListAPIView, CreateAPIView
from .models import CmdTemplate, CmdTemplateCategory
from .serializers import CmdTemplateModelSerialzer, CmdTemplateCategoryModelSerialzer
from rest_framework.permissions import IsAuthenticated


class TemplateView(ListAPIView, CreateAPIView):
    # 获取所有执行模板
    permission_classes = [IsAuthenticated]
    queryset = CmdTemplate.objects.all()
    serializer_class = CmdTemplateModelSerialzer


class TemplateCategoryView(ListAPIView, CreateAPIView):
    # 获取执行模板类别
    permission_classes = [IsAuthenticated]
    queryset = CmdTemplateCategory.objects.all()
    serializer_class = CmdTemplateCategoryModelSerialzer

mtask.serializers,代码:

from rest_framework import serializers
from .models import CmdTemplateCategory, CmdTemplate

class CmdTemplateModelSerialzer(serializers.ModelSerializer):
    category_name = serializers.CharField(source="category.name", read_only=True)

    class Meta:
        model = CmdTemplate
        fields = ["id", "name", "cmd", "description", "category_name", "category"]


class CmdTemplateCategoryModelSerialzer(serializers.ModelSerializer):
    class Meta:
        model = CmdTemplateCategory
        fields = "__all__"

六 代码发布

在接下来的学习中,我们要在我们的运维平台上添加代码发布功能,实际上就是要实现一套自动化项目构建与部署方案。

发布方案

传统代码发布

image-20210106153814329

  • 开发者开发代码,开发完毕后将代码打包,提交给运维人员Ops
  • 运维人员获取包,手工将包部署到对应的环境Env当中
  • 运维人员部署完毕后,通知测试人员环境部署完毕
  • 测试人员开始进行测试,测试对应功能是否正确,进行缺陷管理
  • 测试完毕后若有Bug,开发进行修复,修复后则重新开始进行步骤1的操作
  • 所以缺陷修复并测试通过后,项目发布上线

在这个过程当中,开发团队开发编码,打包提交,但没有以常规、可重复的方式安装/部署产品。因此在整个周期中,安装/部署任务(以及其它支持任务)就会留给了运维团队来负责,而运维人员部署完成以后才通知测试人员进行测试,这经常导致很多混乱和问题,因为运维团队在后期才开始介入,并且必须在短时间内完成工作。同样开发团队也会因为这种模式而经常处于不利地位 (因为开发人员没有充分测试产品的安装/部署功能),这往往导致开发团队和运维团队、测试团队之间严重脱节和缺乏合作。

这种情况在中国开发行业中持续了很久,直到2006年,在中国软件产业发展高峰论坛上迎来了一位便捷开发布道师——Martin Fowler(马丁·福勒),他在发表演讲时提到了一个让人无法理解的事情:“原本为期需要8个月构建交付的项目,只需要两个月时就已经上线,并开始向客户收钱了。”,以此把敏捷开发的最佳实现方案-持续集成这个概念引入了中国。

img

持续集成指的是,频繁地(一天多次)将代码集成到主干。

**持续集成好处主要有两点:

(1)快速发现错误。每完成一点更新,就集成到主干,可以快速发现错误,定位错误也比较容易。

(2)防止分支大幅偏离主干。如果不是经常集成,主干又在不断更新,会导致以后集成的难度变大,甚至难以集成。集成地狱

持续集成的目的,就是让产品可以快速迭代,同时还能保持高质量。它的核心措施是,代码集成到主干之前,必须通过自动化测试,自动化部署。只要有一个测试用例失败,就不能集成。

Martin Fowler说过,"持续集成并不能消除Bug,而是让它们非常容易发现和改正。"

自动化部署-CI/CD

CI/CD 的核心概念是持续集成(Continuous Integration)、持续交付(Continuous Delivery)与持续部署(Continuous Deployment),它实现了从开发、编译、测试、发布、部署自动化的一套自动化构建的流程。

cicd

持续集成(CI)是在源代码变更后自动检测、拉取、构建和(在大多数情况下)进行单元测试的过程,持续集成的目标是快速确保开发人员新提交的变更是好的,并且适合在代码库中进一步使用。持续集成的基本思想是让一个自动化过程监测一个或多个源代码仓库是否有变更。当变更被推送到仓库时,它会监测到更改、下载副本、构建并运行任何相关的单元测试。

监测程序通常是像 Jenkins 这样的应用程序,它还协调管道中运行的所有(或大多数)进程,监视变更是其功能之一。监测程序可以以几种不同方式监测变更。这些包括:

  • 轮询:监测程序反复询问代码管理系统,当代码管理系统有新的变更时,监测程序会“唤醒”并完成其工作以获取新代码并构建/测试它。
  • 定期:监测程序配置为定期启动构建,无论源码是否有变更。理想情况下,如果没有变更,则不会构建任何新内容,因此这不会增加额外的成本。
  • 推送:这与用于代码管理系统检查的监测程序相反。在这种情况下,代码管理系统被配置为提交变更到仓库时将“推送”一个通知到监测程序。最常见的是,这可以以 webhook 的形式完成 —— 在新代码被推送时一个挂勾hook的程序通过互联网向监测程序发送通知。为此,监测程序必须具有可以通过网络接收 webhook 信息的开放端口。

持续交付(CD)通常是指整个流程链(管道),它自动监测源代码变更并通过构建、测试、打包和相关操作运行它们以生成可部署的版本,基本上没有任何人为干预。持续交付的目标是自动化、效率、可靠性、可重复性和质量保障(通过持续测试CT),持续交付包含持续集成(自动检测源代码变更、执行构建过程、运行单元测试以验证变更),持续测试(对代码运行各种测试以保障代码质量),和(可选)持续部署(通过管道发布版本自动提供给用户)。

持续部署(CD)是指能够自动提供持续交付管道中发布版本给最终用户使用的想法。根据用户的安装方式,可能是在云环境中自动部署、app 升级(如手机上的应用程序)、更新网站或只更新可用版本列表。

CI/CD部署流程

image-20220807073549938

实现效果图:

所谓的应用,实际上代表的就是我们要远程发布的代码的项目版本或者项目中的一个功能代码版本。

image-20220807074304367

image-20220807074318784

配置部署的环境与Git仓库地址,同时设置是否在发布成功或事变时设置结果通知。

image-20220807074341917

由于我们进行代码发布的时候,需要选择环境(测试环境、运营环境等等),来区分我们本次将代码发布到什么环境的主机。

image-20210120142752339

环境是针对公司内部的资产根据实际业务进行再次划分的单位。会存在1个服务器,在不同的时间属于多个不同的环境,一个环境有可能配套了多个企业资产。

image-20210120142832689

软件安装

jetkins

官网地址:https://www.jenkins.io/zh/

安装文档:https://www.jenkins.io/zh/doc/book/installing/

系统要求

最低推荐配置:

  • 256MB可用内存(JVM)
  • 1GB可用磁盘空间(作为一个Docker容器运行jenkins的话推荐10GB)

为小团队推荐的硬件配置:

  • 1GB+可用内存
  • 50 GB+ 可用磁盘空间

软件配置:

  • Java 8—无论是Java运行时环境(JRE)还是Java开发工具包(JDK)都可以。

注意: 如果将Jenkins作为Docker 容器运行,这不是必需的。

版本说明

jenkins实际上由2个发布版本,分别是:LTS(长期支持版本)与 Weekly(普通发行版本)。

版本号 描述
稳定版 (LTS) LTS (长期支持) 版本每12周从常规版本流中选择,作为该时间段的稳定版本。
每隔 4 周,我们会发布稳定版本,其中包括错误和安全修复反向移植。
定期发布 (每周) 每周都会发布一个新版本,为用户和插件开发人员提供错误修复和功能。

这里,我们直接开发使用 稳定版 (LTS) 。

安装

docker-compose安装jetkins,docker-compose.yaml,代码:

version: '3.7'
services:
  jenkins:
    image: 'jenkins/jenkins:lts-jdk11'
    container_name: jenkins
    restart: always
    user: root
    environment:
      - TZ=Asia/Shanghai
    ports:
      - '8888:8080'
      - '5000:50000'
    volumes:
      - './data/jenkins:/var/jenkins_home'

注释版:

version: '3.7'   # docker-compose版本,目前最新版本是3.9版本,此处我们使用3.7即可。
services:          # 容器服务列表,一个docker-complse.yaml文件中只能有一个services
  jenkins:         # 服务名
    image: 'jenkins/jenkins:lts-jdk11'   # 当前容器的基础镜像
    container_name: jenkins   # 容器名
    restart: always                   # 设置开机自启,注意:如果公司安装的不是docker,而是podman的话。podman是没有这个配置的,如果要设置容器开机自启,只能借助python的supervisor这样的进程管理器来启动。
    user: root                          # 以root用户身份启动容器
    environment:                    # 容器内系统环境变量。
      - TZ=Asia/Shanghai       # 设置时区和国际化本地化
    ports:                                # 容器内部与宿主机之间的端口映射: 宿主机端口:容器端口
      - '8888:8080'
      - '5000:50000'                # 如果是windows下使用docker-desktop要调整50000端口为其他端口,因为50000被windows虚拟机hyper-V占用了。linux或macOS没这个问题
    volumes:                          # 逻辑卷配置,设置目录映射:宿主机路径: 容器内部路径
      - './data/jenkins:/var/jenkins_home'

拉取镜像启动docker容器(注意:要保证当前开发电脑上已经安装了docker、docker-compose,docker-compose依赖于python环境)。

# cd 项目根目录下,创建上面的 docker-compose.yaml
docker-compose up -d

# 如果要关闭当前docker-compose.yaml中所有的容器服务,则可以使用
docker-compose down

# 如果要查看某个容器运行过程中的日志
docker logs <容器名>
# 监控容器的日志
docker logs -f <容器名>

安装完成以后,等待2分钟左右,可以通过浏览器访问http://127.0.0.1:8888(如果你设置的也是这个端口的话)访问jenkins的管理站点。效果如下:

image-20220807082912490

按界面中所说,进入找到容器内部/var/jenkins_home目录的映射路径./data/jenkins目录下的初始化密码文件复制密码,点击"继续"。

image-20220807083048843

登陆后续界面如下:

image-20220807083133927

建议选择“安装推荐的插件”,若插件安装失败,多试几次即可),当然,也可以选择右边的自定义插件安装,先选择不安装插件,先进去也可以。

image-20220807083321579

插件下载较慢是由于插件源服务器在国外,可以根据以下教程切换插件源服务器地址改成国内的。

当然也可以一直重试到下载完成为止。

更换为国内插件源

方式1:

选择右边的自定义插件安装,先选择不安装插件,在配置管理员账号进入到jenkins管理页面时,点击"Manage Jenkins"--->"Manage Plugins"--->"Advanced"

image-20220807085603818

将上图的URL地址改为清华源并点击提交即可:https://mirrors.tuna.tsinghua.edu.cn/jenkins/updates/update-center.json

方式2:

更改配置文件(jenkins_home/updates/default.json),我们现在的jenkins应用安装在docker并做了数据映射,因此直接在宿主机下进行修改即可。

image-20220807083657800

因为default.json配置文件配置内容较多,修改的地址也很多,因此建议使用代码编辑器替换(Ctrl+H/Ctrl+R)或者使用sed命令进行替换:

sed -i 's#https://updates.jenkins.io/download#https://mirrors.tuna.tsinghua.edu.cn/jenkins#g' default.json && sed -i 's#http://www.google.com#https://www.baidu.com#g' default.json

完成以后,重启jenkins即可。

安装完成以后,创建管理员。

注意:

老师不知道各位同学的密码!!!自己设置的麻烦自己记一下哈。

image-20220807084628321

实例配置,默认点击继续即可。

image-20220807084730598

配置完成!

image-20220807084811373

进入主界面,以后登陆的界面如下:

image-20220807084852131

在前面如果没有选择安装推荐的插件,而是选择自定义安装而没有安装插件的同学,可以系统管理->插件管理处,进行插件的安装与卸载操作。常用的插件:git、pipeline、Blue Ocean、Allure等等。

image-20220807085250252

基本使用

接下来,我们快速使用jenkins来完成一个工程(就是一个项目的CI构建流程)。

image-20220807090418130

填写任务名称,并勾选默认插件,此处我们选择第一个"freestyle project"。

image-20220807090338851

上面的操作项目于创建了一个项目工程的发布流程。

image-20220807091007792

image-20220807091038299

image-20220807121659671

image-20220807091122735

保存成功以后,可以在demo工程的管理菜单左侧选择立即构建。

image-20220807091439271

等待以后,可以点击查看构建历史:

image-20220807091618526

查看构建过程中的控制台输出。

image-20220807092156936

系统配置

中文支持

默认是没有该配置的,需要安装额外安装中文扩展插件。安装中文扩展,等待jenkins重启。

image-20220807131812612

重启完成以后,如果出现部分页面翻译一半的情况,可以打开系统设置,找到Locale选项,设置中文,接着访问http://127.0.0.1:8888/restart进行重启。

image-20220807131336782

凭据管理

所谓的凭据就是当前jenkins所在服务器对远程服务器节点进行操作的登陆凭证(可以是账号密码,也可以是sshkey)

image-20220807134639184

image-20220807134741857

image-20220807134803406

image-20220807134716272

image-20220807140324269

节点管理

节点(node),实际上就是jenkins用于在分布式主机下执行构建任务的服务器。

点击进入节点管理功能。

image-20220807124932584

右侧是节点名称。点击左侧红框位置可以新建节点。

image-20220807125019994

节点名称,可以自定义的名称,但最好将远程服务器的ip地址或者计算机名填上,便于后期维护查看。

image-20220807135123438

节点连接配置

image-20220807135526068

image-20220807135555097

在节点对应的服务器上,使用which git,可以查看git命令的路径位置。

使用echo $JAVE_HOME,可以查看java的工作目录。

image-20220807135627721

完成上面配置以后,点击"保存"。

并在新建节点对应的服务器(也就是上面添加的192.168.233.129)修改jenkins工作目录的权限并为jenkins设置java链接文件。

# 这里 /var/jenkins/workspace 为上述步骤设置的节点的工作目录
sudo mkdir -p /var/jenkins/workspace/jdk/bin/

sudo chown -P moluo:moluo /var/jenkins

which java
#  which java 命令的结果,/usr/bin/java,然后创建软连接
sudo ln -s /usr/bin/java /var/jenkins/workspace/jdk/bin/java

节点配置成功。

image-20220807140655654

用户管理

在jenkins安装完成以后,默认需要创建了一个超级管理员。但是在企业开发中,肯定不是所有人都使用超管账号的,而且不同的团队人员,能使用jenkins的功能权限应该也是不一样的。所以需要进行用户的账号分配以及权限分配。

从系统配置中点击全局安全配置,设置开启用户管理。

image-20220807154517945

image-20220807154838325

点击进入用户管理功能。

image-20220807154903279

点击"新建用户"

image-20220807141107679

删除用户。

image-20220807155042377

指定用户分配权限。

image-20220807155650655

通知配置

jenkins在构建任务完成以后,可以设置结果通知的。它支持邮件通知、企业微信、钉钉等等,但是都需要安装插件才可以使用。

这里,我们使用邮件通知看下jenkins的通知效果。

安装插件

进入系统管理->插件管理->可选插件,安装Email Extension Template、Email Extension Plugin和Build Timestamp插件

image-20220807142414119

安装等待jenkins重启。

在系统配置->设置邮件发送人的邮箱地址。

image-20220807160901504

开启构建任务完成以后的邮件发送规则。

image-20220807161131228

image-20220807161216460

配置邮件通知。

登陆要使用的SMTP服务器所在的站点配置,设置第三方邮件发送服务。

SMTP(简单邮件发送协议,Simple Mail Transfer Protocol)服务器,就是邮件服务器所在的网关地址。

image-20220807162422697

image-20220807162717648

image-20220807162801728

完成上面配置以后,可以通过点击"通过发送测试邮件测试配置"进行发送测试邮件,验证上面的配置是否正确!

image-20220807163107584

配置邮件模板内容

进入系统管理 - 系统配置,配置获取的时间戳格式 用于发送邮件时获取log和html报告为邮件附件
在这里插入图片描述

配置发送邮件账号与邮件类型
在这里插入图片描述

设置默认收件、邮件标题和邮件内容
在这里插入图片描述

jenkins提供的邮件发送变量

变量名 描述
$PROJECT_NAME 构建任务的项目名(job名称)
$BUILD_NUMBER 构建任务的编号ID
$BUILD_STATUS 构建任务的结果
$CAUSE 构建任务的失败原因
$PROJECT_URL 构建任务的详情URL地址

default content(默认邮件模板):

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${PROJECT_NAME}-第${BUILD_NUMBER}次构建日志</title>
</head>
<body leftmargin="8" marginwidth="0" topmargin="8" marginheight="4" offset="0">
    <table width="95%" cellpadding="0" cellspacing="0"  style="font-size: 11pt; font-family: Tahoma, Arial, Helvetica, sans-serif">
        <tr>本邮件由jenkins系统自动发出,无需回复,以下为${PROJECT_NAME }项目构建信息</br>
            <td><font color="#CC0000">构建结果 - ${BUILD_STATUS}</font></td>
        </tr>
        <tr>
            <td><br />
            <b><font color="#0B610B">构建信息</font></b>
            <hr size="2" width="100%" align="center" /></td>
        </tr>
        <tr>
            <td>
                <ul>
                    <li>项目名称:${PROJECT_NAME}</li>
                    <li>构建编号:第${BUILD_NUMBER}次构建</li>
                    <li>触发原因:${CAUSE}</li>
                    <li>构建状态:${BUILD_STATUS}</li>
                    <li>项目URL:<a href="${PROJECT_URL}">${PROJECT_URL}</a></li>
                    <li>工作目录:<a href="${PROJECT_URL}ws">${PROJECT_URL}ws</a></li>
                    <li>构建URL:<a href="${BUILD_URL}">${BUILD_URL}</a></li>
                    <li>构建日志: <a href="${BUILD_URL}console">${BUILD_URL}console</a></li>
                    <li>测试报告:<a href="${BUILD_URL}HTML_20Report/">${BUILD_URL}HTML_20Report/</a></li>
                </ul>
                <h4><font color="#0B610B">失败用例</font></h4>
                <hr size="2" width="100%" />$FAILED_TESTS<br/>
                <h4><font color="#0B610B">最近提交版本(git:$GIT_REVISION)</font></h4>
                <hr size="2" width="100%" />
                <ul>
                ${CHANGES_SINCE_LAST_SUCCESS, reverse=true, format="%c", changesFormat="<li>%d[%a] %m</li>"}
                </ul>
                    详细提交: <a href="${PROJECT_URL}changes">${PROJECT_URL}changes</a><br/>
            </td>
        </tr>
    </table>
</body>
</html>

Default Triggers(发送邮件的触发规则):
image-20220807171511900

注:配置完成后可通过发送测试邮件是否配置正确。

Gitlab

gitlab是一个类似github/giree的源码托管平台,是一个开源项目,经常在企业中用于构建私有git仓库,托管企业内部的项目源代码,支持使用http以及ssh协议进行源码管理,支持使用svn/git源码管理工具。

官方地址:https://gitlab.com/

使用docker-compose安装Gitlab

docker-compose.yaml,代码:

version: '3.7'
services:
  gitlab:
    image: 'gitlab/gitlab-ce:latest' # gitlab的镜像,如果已经有了,指定自己的镜像版本即可
    container_name: gitlab # 生成的docker容器的名字
    restart: always
    environment:
      GITLAB_OMNIBUS_CONFIG: |
        external_url 'http://192.168.101.8:8993' # 此处填写所在服务器ip若有域名可以写域名
        gitlab_rails['gitlab_shell_ssh_port'] = 2224
    ports:
      - '8993:8993' # 此处端口号须与 external_url 中保持一致,左边和右边都要一样
      - '2224:22' # 这里的2224和上面的2224一致,但是右边必须是22,不能是其他
    volumes:
      #将相关配置映射到当前目录下的config目录
      - './conf/gitlab:/etc/gitlab'
      #将日志映射到当前目录下的logs目录
      - './logs/gitlab:/var/log/gitlab'
      #将数据映射到当前目录下的data目录
      - './data/gitlab:/var/opt/gitlab'

  jenkins:
    image: 'jenkins/jenkins:lts-jdk11'
    container_name: jenkins
    restart: always
    user: root
    environment:
      - TZ=Asia/Shanghai
    ports:
      - '8888:8080'
      - '5000:50000'
    volumes:
      - './data/jenkins:/var/jenkins_home'

终端下关闭原来的jenkins,并重启启动两个容器即可。

docker-compose down 
docker-compose up -d

gitlab容器启动以后,需要等待几分钟,接着在浏览器访问登陆地址:http://192.168.101.8:8993/

首次登陆需要创建一个管理员账号。

基本使用

注意:

老师不知道各位同学的密码!!!自己设置的麻烦自己记一下哈。

刚安装完成的gitlab默认已经内置了一个超级管理员root,密码保存在文件配置目录下initial_root_password文件中。

image-20220807174811289

image-20220807174923189

直接使用上面的账号和密码登陆即可。

image-20220807085758672

登陆成功以后,配置中文界面。

image-20220807175059923

image-20220807175140696

API调用

不管是jenkins还是gitlab实际上都提供了外界操作的http api接口给开发者进行远程调用的。

Gitlab RestAPI 文档:http://192.168.101.8:8993/help/api/api_resources.md

要使用Gitlab RestAPI需要配置访问令牌。

image-20220807180743321

image-20220807180802431

image-20220807180817506

有了令牌,就可以通过postman或者编程代码,使用http请求操作gitlab了。

image-20220807181000029

jenkins RestAPI:http://127.0.0.1:8888/api/

访问格式:http://账号:密码@服务端地址:端口/job/任务名/build

jenkins状态的API:http://127.0.0.1:8888/api/json?pretty=true

Python调用Gitlab

操作文档:https://python-gitlab.readthedocs.io/en/master/api-usage.html

安装

pip install python-gitlab

基本使用

连接gitlab

import gitlab
url = "http://192.168.101.8"
token = "yussaW8kaV26qhbOL9A3pMrScD7D6HdHRU2vPufs"
gl = gitlab.Gitlab(url, token)

常用操作

方法 描述
projects =gl.projects.list(page=1) 获取第一页project
projects=gl.projects.list(all=True) 获取所有的project
projects=gl.projects.get(1) 通过指定id 获取 project 对象
projects = gl.projects.list(search='keyword') 查找项目
projects = gl.projects.list(visibility='public') 获取公开的项目,参数visibility的值:
public 公有项目
internal 内部项目
private 私有项目
project = gl.projects.create({'name': 'test2', 'description': '测试项目2','visibility': 'public'}) 创建一个项目
branches = project.branches.list() 通过指定project对象获取该项目的所有分支
branch = project.branches.get('main') 获取指定分支的属性
branch = project.branches.create({'branch_name': 'feature/user','ref': 'main'}) 创建分支
project.branches.delete('feature/user') 删除分支
branch.protect() 分支保护[v4版本没有该功能]
branch.unprotect() 取消保护[v4版本没有该功能]
tags = project.tags.list() 获取指定项目的所有tags
tag = project.tags.get('v1.0') 获取某个指定tag 的信息
tag = project.tags.create({'tag_name':'v1.0', 'ref':'main'}) 创建一个tag
tag.set_release_description('v1.0 release') 设置tags 说明
project.tags.delete('v1.0') 删除tags
tag.delete() 删除tags
commits = project.commits.list() 获取所有commit
data = {
'branch_name': 'master', # v3
'commit_message': 'commit message description',
'actions': [
{
'action': 'create',
'file_path': '.',
'content': 'blah'
}
]
}
commit = project.commits.create(data)
创建一个commit
commit = project.commits.get('d3a5171b') 获取指定commit
mrs = project.mergerequests.list() 获取指定项目的所有merge request
mr = project.mergerequests.get(mr_id) 获取 指定merge request
project.mergerequests.create({'source_branch':'cool_feature', 'target_branch':'master', 'title':'merge cool feature', }) 创建一个merge request
mr.description = 'merge description' 更新一个merge request 的描述
mr.state_event = 'close'
mr.save()
开关一个merge request (close or reopen)
project.mergerequests.delete(mr_id) 删除一个merge request
mr.merge() 通过一个merge request
mrs = project.mergerequests.list(state='merged', sort='asc') 指定条件过滤 所有的merge request
state:all、merged、opened、closed
sort:asc、desc
gl.users.list() 所有用户列表

基本使用

import gitlab

if __name__ == '__main__':
    """获取所有项目列表"""
    url = "http://192.168.101.8:8993/"
    token = "LAgbKLyaysE4UjPyX1EV"
    gl = gitlab.Gitlab(url, token)
    # print(gl)

    # """获取所有项目列表"""
    # projects = gl.projects.list(all=True)
    # for project in projects:
    #     print(project.id, project.name ,project.description)
    #
    #
    # """获取单个项目"""
    # project = gl.projects.get(2)
    #
    # print("项目ID", project.id)
    # print("项目描述", project.description)
    # print("项目名", project.name)
    # print("创建时间", project.created_at)
    # print("默认主分支", project.default_branch)
    # print("tag数量", len(project.tag_list))
    # print("仓库地址[ssh]", project.ssh_url_to_repo)
    # print("仓库地址[http]", project.http_url_to_repo)
    # print("仓库访问地址", project.web_url)
    # print("仓库可见性", project.visibility)  # internal 内部项目 public 开源项目   private私有项目
    # print("仓库派生数量", project.forks_count)
    # print("仓库星标数量", project.star_count)
    # print("仓库拥有者", getattr(project, "owner", None)) # 因为默认的第一个仓库是没有拥有者的!!
    #
    #
    #
    # """
    # {
    #     'id': 2,
    #     'description': '自动化运维平台',
    #     'name': 'uric',
    #     'name_with_namespace': 'Administrator / uric',
    #     'path': 'uric',
    #     'path_with_namespace': 'root/uric',
    #     'created_at': '2022-08-20T03:34:48.446Z',
    #     'default_branch': 'main',
    #     'tag_list': [],
    #     'topics': [],
    #     'ssh_url_to_repo': 'ssh://git@192.168.101.8:2224/root/uric.git',
    #     'http_url_to_repo': 'http://192.168.101.8:8993/root/uric.git',
    #     'web_url': 'http://192.168.101.8:8993/root/uric',
    #     'readme_url': 'http://192.168.101.8:8993/root/uric/-/blob/main/README.md',
    #     'avatar_url': None,
    #     'forks_count': 0,
    #     'star_count': 0,
    #     'last_activity_at': '2022-08-20T03:34:48.446Z',
    #     'namespace': {
    #         'id': 1,
    #         'name': 'Administrator',
    #         'path': 'root',
    #         'kind': 'user',
    #         'full_path': 'root',
    #         'parent_id': None,
    #         'avatar_url': 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
    #         'web_url': 'http://192.168.101.8:8993/root'
    #     },
    #     '_links': {
    #         'self': 'http://192.168.101.8:8993/api/v4/projects/2',
    #         'issues': 'http://192.168.101.8:8993/api/v4/projects/2/issues',
    #         'merge_requests': 'http://192.168.101.8:8993/api/v4/projects/2/merge_requests',
    #         'repo_branches': 'http://192.168.101.8:8993/api/v4/projects/2/repository/branches',
    #         'labels': 'http://192.168.101.8:8993/api/v4/projects/2/labels',
    #         'events': 'http://192.168.101.8:8993/api/v4/projects/2/events',
    #         'members': 'http://192.168.101.8:8993/api/v4/projects/2/members'
    #     },
    #     'packages_enabled': True,
    #     'empty_repo': False,
    #     'archived': False,
    #     'visibility': 'internal',
    #     'owner': {
    #         'id': 1,
    #         'username': 'root',
    #         'name': 'Administrator',
    #         'state': 'active',
    #         'avatar_url': 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
    #         'web_url': 'http://192.168.101.8:8993/root'},
    #         'resolve_outdated_diff_discussions': False,
    #         'container_expiration_policy': {'cadence': '1d',
    #         'enabled': False,
    #         'keep_n': 10,
    #         'older_than': '90d',
    #         'name_regex': '.*',
    #         'name_regex_keep': None,
    #         'next_run_at': '2022-08-21T03:34:49.221Z'},
    #         'issues_enabled': True,
    #         'merge_requests_enabled': True,
    #         'wiki_enabled': True,
    #         'jobs_enabled': True,
    #         'snippets_enabled': True,
    #         'container_registry_enabled': True,
    #         'service_desk_enabled': False,
    #         'service_desk_address': None,
    #         'can_create_merge_request_in': True,
    #         'issues_access_level': 'enabled',
    #         'repository_access_level': 'enabled',
    #         'merge_requests_access_level': 'enabled',
    #         'forking_access_level': 'enabled',
    #         'wiki_access_level': 'enabled',
    #         'builds_access_level': 'enabled',
    #         'snippets_access_level': 'enabled',
    #         'pages_access_level': 'private',
    #         'operations_access_level': 'enabled',
    #         'analytics_access_level': 'enabled',
    #         'container_registry_access_level': 'enabled',
    #         'emails_disabled': None,
    #         'shared_runners_enabled': True,
    #         'lfs_enabled': True,
    #         'creator_id': 1,
    #         'import_status': 'none',
    #         'open_issues_count': 0,
    #         'ci_default_git_depth': 50,
    #         'ci_forward_deployment_enabled': True,
    #         'ci_job_token_scope_enabled': False,
    #         'public_jobs': True,
    #         'build_timeout': 3600,
    #         'auto_cancel_pending_pipelines': 'enabled',
    #         'build_coverage_regex': None,
    #         'ci_config_path': None,
    #         'shared_with_groups': [],
    #         'only_allow_merge_if_pipeline_succeeds': False,
    #         'allow_merge_on_skipped_pipeline': None,
    #         'restrict_user_defined_variables': False,
    #         'request_access_enabled': True,
    #         'only_allow_merge_if_all_discussions_are_resolved': False,
    #         'remove_source_branch_after_merge': True,
    #         'printing_merge_request_link_enabled': True,
    #         'merge_method': 'merge',
    #         'squash_option': 'default_off',
    #         'suggestion_commit_message': None,
    #         'merge_commit_template': None,
    #         'squash_commit_template': None,
    #         'auto_devops_enabled': True,
    #         'auto_devops_deploy_strategy': 'continuous',
    #         'autoclose_referenced_issues': True,
    #         'repository_storage': 'default',
    #         'keep_latest_artifact': True,
    #         'permissions': {
    #             'project_access': {
    #                 'access_level': 40,
    #                 'notification_level': 3
    #             },
    #             'group_access': None
    #         }
    #     }
    # """

    # """根据项目名搜索项目"""
    # projects = gl.projects.list(search='uric')
    # print(projects)


    # """根据项目的可见性列出符合条件的项目"""
    # # projects = gl.projects.list(visibility='public')  # 公有项目列表
    # projects = gl.projects.list(visibility='private') # 私有项目列表
    # # projects = gl.projects.list(visibility='internal') # 内部项目列表
    # print(projects)

    """创建一个项目"""
    # project = gl.projects.create({
    #     'name': 'test2',   # 项目名,不要使用中文或其他特殊符号
    #     # 'path': 'test2',   # 访问路径,如果不设置path,则path的值默认为name
    #     'description': '测试项目2',
    #     'visibility': 'public'
    # })

    # """更新一个项目"""
    # # 先获取项目
    # project = gl.projects.get(5)
    # # 在获取了项目以后,直接对当前项目对象设置属性进行覆盖,后面调用save方法即可保存更新内容
    # project.description = "测试项目2的描述信息被修改了1次"
    # project.save()

    # """删除一个项目"""
    # project = gl.projects.get(5)
    # project.delete()



    # """分支管理:获取所有分支"""
    # project = gl.projects.get(3)
    # # branches = project.branches.list()
    # # print(branches)  # [<ProjectBranch name:main>]
    #
    # """根据名称获取一个分支"""
    # project = gl.projects.get(3)
    # branch = project.branches.get('main')
    # print("分支名称:", branch.name)
    # print("分支最新提交记录:", branch.commit)
    # print("分支合并状态:", branch.merged)
    # print("是否属于保护分支:", branch.protected)
    # print("当前分支是否可以推送代码:", branch.can_push)
    # print("是否是默认分支:", branch.default)
    # print("当前分支的访问路径:", branch.web_url)
    #
    # """
    # {
    #     'name': 'main',
    #     'commit': {
    #         'id': 'be71595d791b3437dee7e36a9dc221376392912f',
    #         'short_id': 'be71595d',
    #         'created_at': '2022-08-20T04:00:44.000+00:00',
    #         'parent_ids': [],
    #         'title': 'Initial commit',
    #         'message': 'Initial commit',
    #         'author_name': 'Administrator',
    #         'author_email': 'admin@example.com',
    #         'authored_date': '2022-08-20T04:00:44.000+00:00',
    #         'committer_name': 'Administrator',
    #         'committer_email': 'admin@example.com',
    #         'committed_date': '2022-08-20T04:00:44.000+00:00',
    #         'trailers': {},
    #         'web_url': 'http://192.168.101.8:8993/root/tools/-/commit/be71595d791b3437dee7e36a9dc221376392912f'
    #     },
    #     'merged': False,
    #     'protected': True,
    #     'developers_can_push': False,
    #     'developers_can_merge': False,
    #     'can_push': True,
    #     'default': True,
    #     'web_url': 'http://192.168.101.8:8993/root/tools/-/tree/main'
    # """

    # """给指定项目创建分支"""
    # project = gl.projects.get(3)
    # branch = project.branches.create({'branch': 'feature/user', 'ref': 'main'})
    # print(branch)

    """更新分支的属性【gitbal的v4版本中没有保护分支和取消保护分支的功能】"""
    # project = gl.projects.get(3)
    # branch = project.branches.get('feature/user')
    # # 设置当前分支为保护分支
    # branch.protect()


    # """删除一个分支"""
    # # 注意,只有一个保护分支时,是不能删除当前分支的
    # project = gl.projects.get(3)
    # project.branches.delete('feature/user')

    # """创建一个tag标签"""
    # project = gl.projects.get(3)
    # tag = project.tags.create({'tag_name': 'v1.0', 'ref': 'main'})
    # print(tag)

    # """获取所有tag标签"""
    # project = gl.projects.get(3)
    # tags = project.tags.list(all=True)
    # print(tags)

    # """获取一个tag标签信息"""
    # project = gl.projects.get(3)
    # tag = project.tags.get('v1.0')
    # print("标签名", tag.name)
    # print("标签的版本描述", tag.message)
    # print("标签的唯一标记(版本号)", tag.target) # 实际上就是本次创建标签时的分支最后一条commit的版本号
    # print("标签的最后一个commit记录", tag.commit)
    # print("当前标签是否发布", tag.release)
    # print("当前标签是佛属于保护标签", tag.protected)
    #
    # """
    # {
    #     'name': 'v1.0',
    #     'message': '',
    #     'target': 'be71595d791b3437dee7e36a9dc221376392912f',
    #     'commit': {
    #         'id': 'be71595d791b3437dee7e36a9dc221376392912f',
    #         'short_id': 'be71595d',
    #         'created_at': '2022-08-20T04:00:44.000+00:00',
    #         'parent_ids': [],
    #         'title': 'Initial commit',
    #         'message': 'Initial commit',
    #         'author_name': 'Administrator',
    #         'author_email': 'admin@example.com',
    #         'authored_date': '2022-08-20T04:00:44.000+00:00',
    #         'committer_name': 'Administrator',
    #         'committer_email': 'admin@example.com',
    #         'committed_date': '2022-08-20T04:00:44.000+00:00',
    #         'trailers': {},
    #         'web_url': 'http://192.168.101.8:8993/root/tools/-/commit/be71595d791b3437dee7e36a9dc221376392912f'
    #     },
    #     'release': None,
    #     'protected': False
    # }
    # """


    # """指定项目的commit提交记录"""
    # project = gl.projects.get(3)
    # commits = project.commits.list(all=True)
    # print(commits)

    # """根据版本号来获取commit记录"""
    # project = gl.projects.get(3)
    # commit = project.commits.get("be71595d791b3437dee7e36a9dc221376392912f")
    # print(commit)
    # """
    # {
    #     'id': 'be71595d791b3437dee7e36a9dc221376392912f',
    #     'short_id': 'be71595d',
    #     'created_at': '2022-08-20T04:00:44.000+00:00',
    #     'parent_ids': [],
    #     'title': 'Initial commit',
    #     'message': 'Initial commit',
    #     'author_name': 'Administrator',
    #     'author_email': 'admin@example.com',
    #     'authored_date': '2022-08-20T04:00:44.000+00:00',
    #     'committer_name': 'Administrator',
    #     'committer_email': 'admin@example.com',
    #     'committed_date': '2022-08-20T04:00:44.000+00:00',
    #     'trailers': {},
    #     'web_url': 'http://192.168.101.8:8993/root/tools/-/commit/be71595d791b3437dee7e36a9dc221376392912f',
    #     'stats': {
    #         'additions': 3,
    #         'deletions': 0,
    #         'total': 3},
    #         'status': None,
    #         'project_id': 3,
    #         'last_pipeline': None
    #     }
    # """


    # """创建一个commit版本"""
    # project = gl.projects.get(3)
    # data = {
    # 'branch': 'main',
    # 'commit_message': '提交代码的版本描述',
    #     'actions': [
    #         {
    #         'action': 'create',  # 创建文件
    #         # 'action': 'update',  # 更新文件
    #         # 'action': 'delete',    # 删除文件
    #         'file_path': 'docs/uric_api/logs/uric.log', # 文件路径
    #         'content': '上传文件的内容'  # 文件内容
    #         }
    #     ]
    # }
    #
    # commit = project.commits.create(data)


    """获取用户列表"""
    # print(gl.users.list())  # [<User id:1 username:root>]

    """获取单个用户信息"""
    user = gl.users.get(1)
    print(user)


封装工具类

import gitlab


class Gitlabapi(object):
    VISIBILITY = {
        "private": "私有",
        "internal": "内部",
        "public": "公开"
    }

    def __init__(self, url, token):
        self.url = url
        self.token = token
        self.conn = gitlab.Gitlab(self.url, self.token)

    def get_projects(self):
        """
        获取所有的项目
        :return:
        """
        projects = self.conn.projects.list(all=True, iterator=True)
        projectslist = []
        for pro in projects:
            projectslist.append(pro.attributes)  # pro.attributes 项目的所有属性
        return projectslist

    def get_projects_visibility(self, visibility="public"):
        """
        根据可见性属性获取项目
        :param visibility:
        :return:
        """
        if visibility in self.VISIBILITY:
            attribute = visibility
        else:
            attribute = "public"
        projects = self.conn.projects.list(all=True, visibility=attribute)
        projectslist = []
        for pro in projects:
            projectslist.append(pro.attributes)
        return projectslist

    def get_projects_id(self, project_id):
        """
        根据id获取项目
        :param project_id:
        :return:
        """
        res = self.conn.projects.get(project_id)
        return res.attributes

    def get_projects_search(self, name):
        """
        模糊搜索项目
        :param name:
        :return:
        """
        projects = self.conn.projects.list(search=name)
        projectslist = []
        for pro in projects:
            projectslist.append(pro.attributes)
        return projectslist

    def create_project(self, name):
        """
        创建项目
        :param name:
        :return:
        """
        res = self.conn.projects.create({"name": name})
        return res.attributes

    def get_project_brances(self, project_id):
        """
        获取项目所有分支
        :param project_id:
        :return:
        """
        project = self.conn.projects.get(project_id)
        brancheslist = []
        for branches in project.branches.list():
            brancheslist.append(branches.attributes)
        return brancheslist

    def get_project_brance_attribute(self, project_id, branch):
        """
        获取指定项目指定分支
        :param project_id:
        :param branch:
        :return:
        """
        project = self.conn.projects.get(project_id)
        res = project.branches.get(branch)
        return res.attributes

    def create_get_project_brance(self, project_id, branch, ref="main"):
        """
        创建分支
        :param project_id:
        :param branch:
        :param ref:
        :return:
        """
        project = self.conn.projects.get(project_id)
        res = project.branches.create({"branch": branch, "ref": ref})
        return res.attributes

    def delete_project_brance(self, project_id, branch):
        """
        删除分支
        :param project_id:
        :param branch:
        :return:
        """
        project = self.conn.projects.get(project_id)
        project.branches.delete(branch)

    def protect_project_brance(self, project_id, branch, is_protect=None):
        """
        分支保护[v3.0可用, V4.0不可用]
        :param project_id:
        :param branch:
        :param is_protect:
        :return:
        """
        project = self.conn.projects.get(project_id)
        branch = project.branches.get(branch)
        if is_protect == "protect":
            branch.unprotect()
        else:
            branch.protect()

    def get_project_tags(self, project_id):
        """
        获取所有的tags标签
        :param project_id:
        :return:
        """
        project = self.conn.projects.get(project_id)
        tags = project.tags.list()
        taglist = []
        for tag in tags:
            taglist.append(tag.attributes)
        return taglist

    def get_project_tag_name(self, project_id, name):
        """
        获取指定的tag
        :param project_id:
        :param name:
        :return:
        """
        project = self.conn.projects.get(project_id)
        tags = project.tags.get(name)
        return tags.attributes

    def create_project_tag(self, project_id, name, branch="master"):
        """
        创建tag
        :param project_id:
        :param name:
        :param branch:
        :return:
        """
        project = self.conn.projects.get(project_id)
        tags = project.tags.create({"tag_name": name, "ref": branch})
        return tags.attributes

    def delete_project_tag(self, project_id, name):
        """
        删除tags
        :param project_id:
        :param name:
        :return:
        """
        project = self.conn.projects.get(project_id)
        project.tags.delete(name)

    def get_project_commits(self, project_id):
        """
        获取所有的commit
        :param project_id:
        :return:
        """
        project = self.conn.projects.get(project_id)
        commits = project.commits.list()
        commitslist = []
        for com in commits:
            commitslist.append(com.attributes)
        return commitslist

    def get_project_commit_info(self, project_id, commit_id):
        """
        获取指定的commit
        :param project_id:
        :param commit_id:
        :return:
        """
        project = self.conn.projects.get(project_id)
        commit = project.commits.get(commit_id)
        return commit.attributes

    def get_project_merge(self, project_id):
        """
        获取所有的合并请求
        :param project_id:
        :return:
        """
        project = self.conn.projects.get(project_id)
        mergerquests = project.mergerequests.list()
        mergerquestslist = []
        for mergerquest in mergerquests:
            mergerquestslist.append(mergerquest.attributes)
        return mergerquestslist

    def get_project_merge_id(self, project_id, mr_id):
        """
        获取请求的详细信息
        :param project_id:
        :param mr_id:
        :return:
        """
        project = self.conn.projects.get(project_id)
        mrinfo = project.mergerequests.get(mr_id)
        return mrinfo.attributes

    def create_project_merge(self, project_id, source_branch, target_branch, title):
        """
        创建合并请求
        :param project_id:
        :param source_branch:
        :param target_branch:
        :param title:
        :return:
        """
        project = self.conn.projects.get(project_id)
        res = project.mergerequests.create(
            {"source_branch": source_branch, "target_branch": target_branch, "title": title})
        return res

    def update_project_merge_info(self, project_id, mr_id, data):
        """
        更新合并请求的信息
        :param project_id:
        :param mr_id:
        :param data:
        :return:
        """
        # data = {"description":"new描述","state_event":"close"}
        project = self.conn.projects.get(project_id)
        mr = project.mergerequests.get(mr_id)
        if "description" in data:
            mr.description = data["description"]
        if "state_event" in data:
            state_event = ["close", "reopen"]
            if data["state_event"] in state_event:
                mr.state_event = data["state_event"]
        res = mr.save()
        return res

    def delete_project_merge(self, project_id, mr_id):
        """
        删除合并请求
        :param project_id:
        :param mr_id:
        :return:
        """
        project = self.conn.projects.get(project_id)
        res = project.mergerequests.delete(mr_id)
        return res

    def access_project_merge(self, project_id, mr_id):
        """
        允许合并请求
        :param project_id:
        :param mr_id:
        :return:
        """
        project = self.conn.projects.get(project_id)
        mr = project.mergerequests.get(mr_id)
        res = mr.merge()
        return res

    def search_project_merge(self, project_id, state, sort):
        '''
        搜索项目合并请求
        :param id:
        :param state: state of the mr,It can be one of all,merged,opened or closed
        :param sort: sort order (asc or desc)
        :param order_by: sort by created_at or updated_at
        :return:
        '''
        stateinfo = ["merged", "opened", "closed"]
        sortinfo = ["asc", "desc"]
        if state not in stateinfo:
            state = "merged"
        if sort not in sortinfo:
            sort = "asc"
        project = self.conn.projects.get(project_id)
        mergerquests = project.mergerequests.list(state=state, sort=sort)
        mergerquestslist = []
        for mergerquest in mergerquests:
            mergerquestslist.append(mergerquest.attributes)
        return mergerquestslist

    def create_project_commit(self, project_id, branch_name, message, actions):
        """
        创建项目提交记录
        :param project_id:
        :param branch_name:
        :param message:
        :param actions:
        :return:
        """
        project = self.conn.projects.get(project_id)
        data = {
            'branch': branch_name,
            'commit_message': message,
            'actions': actions,
            # 'actions': [{
            #     'action': 'create',
            #     'file_path': 'myreadme',
            #     'contend': 'commit_test'
            # }]
        }
        commit = project.commits.create(data)
        return commit

    def diff_project_branches(self, project_id, source_branch, target_branch):
        """
        比较2个分支
        :param project_id:
        :param source_branch:
        :param target_branch:
        :return:
        """
        project = self.conn.projects.get(project_id)
        result = project.repository_compare(source_branch, target_branch)
        # commits = result["commits"]
        # commits = result["diffs"]
        return result


if __name__ == '__main__':
    url = "http://192.168.101.8:8993/"
    token = "LAgbKLyaysE4UjPyX1EV"
    gl = Gitlabapi(url, token)
    # projects = gl.get_projects()
    projects = gl.get_projects_visibility("internal")
    print(projects)

Python调用Jenkins

官方文档:https://python-jenkins.readthedocs.io/en/latest/

安装python-jenkins

pip install python-jenkins

基本使用

基于密码/Token连接jenkins

import jenkins
    # 基于登陆密码连接jenkins
    # server = jenkins.Jenkins('http://192.168.101.8:8888/', username='admin', password='7bb3d493057242edaf5a9e72c63ca27e')
    # 基于token连接jenkins
    server = jenkins.Jenkins('http://192.168.101.8:8888/', username='admin', password='11217915472cb72a7edb9a4de8113a5928')
    print(server)

token的获取方式

进入用户个人页面 —> 点击左上角的设置 —> API Token —> 添加新 Token。

image-20220820155814261

常用操作

方法 描述
server.get_jobs() 项目列表
server.get_job_info('job名称') 根据名称获取执行项目
server.build_job(name='构建的job名称') 构建项目
server.build_job(name='构建的job名称', parameters='构建的参数,字典类型') 参数化构建项目
server.stop_build('job名称', '构建编号ID') 停止一个正在运行的项目
server.enable_job('job名称') 激活项目状态为可构建
server.disable_job('job名称') 变更项目状态为不可构建
server.delete_job('job名称') 删除项目
last_build_number = server.get_job_info('job名称')['lastBuild']['number'] 获取项目当前构建的最后一次编号
status = server.get_build_info('job名称', last_build_number)['result'] 通过构建编号获取任务状态
状态有4种:SUCCESS、FAILURE、ABORTED、pending
result = server.get_build_console_output(name='job名称', number=last_build_number) 获取项目控制台日志
result = server.get_build_test_report(name='job名称', number=last_build_number) 获取项目测试报告

快速入门,代码:

import jenkins

if __name__ == '__main__':
    """连接jenkins"""
    # 基于登陆密码连接jenkins
    # server = jenkins.Jenkins('http://192.168.101.8:8888/', username='admin', password='7bb3d493057242edaf5a9e72c63ca27e')
    # 基于token连接jenkins
    server = jenkins.Jenkins('http://192.168.101.8:8888/', username='admin', password='11217915472cb72a7edb9a4de8113a5928')
    # print(server)

    # """我是谁?"""
    # user = server.get_whoami()
    # print(user)
    #
    # """jenkins的版本号"""
    # version = server.get_version()
    # print(version)

    # """查看所有的构建任务"""
    # jobs = server.get_jobs()
    # print(jobs)
    # """
    # [{
    #     '_class': 'hudson.model.FreeStyleProject',   # 构建项目的类型  FreeStyleProject 表示自由风格的构建项目
    #     'name': 'demo',                              # 构建项目的名称
    #     'url': 'http://192.168.101.8:8888/job/demo/',   # 访问地址
    #     'color': 'notbuilt',                            # 构建状态  【notbuilt, blue, 】
    #     'fullname': 'demo'                              # 构建项目的名称
    # }]
    # """

    # """获取指定的构建任务信息"""
    # info = server.get_job_info(name="demo")
    # print(info)
    # """
    # {
    #     '_class': 'hudson.model.FreeStyleProject',   # 构建项目类型
    #     'actions': [   # 构建项目的配置配置
    #         {},
    #         {},
    #         {'_class': 'org.jenkinsci.plugins.displayurlapi.actions.JobDisplayAction'},
    #         {'_class': 'com.cloudbees.plugins.credentials.ViewCredentialsAction'}
    #     ],
    #     'description': '测试构建项目',   # 构建项目的描述
    #     'displayName': 'demo',         # 构建项目的名称
    #     'displayNameOrNull': None,
    #     'fullDisplayName': 'demo',
    #     'fullName': 'demo',
    #     'name': 'demo',                # 构建项目的名称
    #     'url': 'http://192.168.101.8:8888/job/demo/',    # 访问地址
    #     'buildable': True,                               # 当前构建项目是否属于可构建状态(激活状态)
    #     'builds': [{                                     # 构建项目的执行记录
    #         '_class': 'hudson.model.FreeStyleBuild',
    #         'number': 1,
    #         'url': 'http://192.168.101.8:8888/job/demo/1/'
    #     }],
    #     'color': 'blue',                                 # 构建项目的执行状态(晴雨表)
    #     'firstBuild': {                                  # 首次构建的结果
    #         '_class': 'hudson.model.FreeStyleBuild',
    #         'number': 1,
    #         'url': 'http://192.168.101.8:8888/job/demo/1/'
    #     },
    #     'healthReport': [{                               # 构建项目的健康报告(晴雨表)
    #         'description': 'Build stability: No recent builds failed.',
    #         'iconClassName': 'icon-health-80plus',
    #         'iconUrl': 'health-80plus.png',
    #         'score': 100                                 # 构建任务的成功率
    #     }],
    #     'inQueue': False,                                # 是否处理队列等待中
    #     'keepDependencies': False,
    #     'lastBuild': {                                   # 上一次构建构建任务的状态
    #         '_class': 'hudson.model.FreeStyleBuild',
    #         'number': 1,
    #         'url': 'http://192.168.101.8:8888/job/demo/1/'},
    #         'lastCompletedBuild': {                     # 上一次完成构建的执行记录
    #             '_class': 'hudson.model.FreeStyleBuild',
    #             'number': 1,
    #             'url': 'http://192.168.101.8:8888/job/demo/1/'
    #         },
    #         'lastFailedBuild': None,                    # 上一次失败记录
    #         'lastStableBuild': {                        # 上次构建状态
    #             '_class': 'hudson.model.FreeStyleBuild',
    #             'number': 1,
    #             'url': 'http://192.168.101.8:8888/job/demo/1/'
    #         },
    #         'lastSuccessfulBuild': {                    # 上一次成功记录
    #             '_class': 'hudson.model.FreeStyleBuild',
    #             'number': 1,
    #             'url': 'http://192.168.101.8:8888/job/demo/1/'
    #         },
    #         'lastUnstableBuild': None,
    #         'lastUnsuccessfulBuild': None,
    #         'nextBuildNumber': 2,
    #         'property': [],
    #         'queueItem': None,
    #         'concurrentBuild': False,
    #         'disabled': False,
    #         'downstreamProjects': [],
    #         'labelExpression': None,
    #         'scm': {'_class': 'hudson.scm.NullSCM'},
    #         'upstreamProjects': []
    #     }
    # """

    # """开始构建任务"""
    # # 如果要构建的任务,不存在,则报错!!
    # build_id = server.build_job(name='demo')
    # print(build_id)

    # """设置构建任务为禁用状态(不可构建状态)"""
    # server.disable_job(name='demo')

    # """设置构建任务为激活激活状态(可构建状态)"""
    # server.enable_job(name="demo")

    # """删除一个构建任务"""
    # server.delete_job(name='demo')

    # """获取项目的最后一次构建编号"""
    # last_build_number = server.get_job_info('demo')['lastBuild']['number']
    # print(last_build_number)

    # """根据构建编号来获取构建结果"""
    # last_build_number = server.get_job_info('demo')['lastBuild']['number']
    # result = server.get_build_info('demo', last_build_number)['result']
    # print(result)  # SUCCESS

    # """根据构建编号获取构建过程中的终端输出内容"""
    # last_build_number = server.get_job_info('demo')['lastBuild']['number']
    # result = server.get_build_console_output(name='demo', number=last_build_number)
    # print(result)

    # """根据构建编号获取构建的测试报告【Allure】"""
    # last_build_number = server.get_job_info('demo')['lastBuild']['number']
    # result = server.get_build_test_report(name='demo', number=last_build_number)
    # print(result)

    """基于已有的任务,生成一份xml配置文档"""
    # config_xml = server.get_job_config(name="demo")
    # print(config_xml)

#     """
#     基于xml构建项目
#     """
#     config_xml = """<project>
# <description>测试构建项目</description>
# <keepDependencies>false</keepDependencies>
# <properties/>
# <scm class="hudson.scm.NullSCM"/>
# <canRoam>true</canRoam>
# <disabled>false</disabled>
# <blockBuildWhenDownstreamBuilding>false</blockBuildWhenDownstreamBuilding>
# <blockBuildWhenUpstreamBuilding>false</blockBuildWhenUpstreamBuilding>
# <triggers/>
# <concurrentBuild>false</concurrentBuild>
# <builders>
# <hudson.tasks.Shell>
#   <command>echo "hello, project-1"</command>
#   <configuredLocalRules/>
# </hudson.tasks.Shell>
# </builders>
# <publishers/>
# <buildWrappers/>
# </project>"""
#
#     server.create_job("project-1", config_xml=config_xml)

封装工具类,代码:

import jenkins

class Jenkinsapi(object):
    def __init__(self,url, user, token):
        self.server_url = url
        self.user = user
        self.token = token
        self.conn = jenkins.Jenkins(self.server_url, username=self.user, password=self.token)

    def get_jobs(self):
        """
        获取所有的构建项目列表
        :return:
        """
        return self.conn.get_jobs()

    def get_job_info(self, job):
        """
        根据项目名获取构建项目
        :param job:
        :return:
        """
        return self.conn.get_job_info(job)

    def build_job(self,job,**kwargs):
        """
        开始构建项目
        :param job:
        :param kwargs:
        :return:
        """
        # dict1 = {"version":11} # 参数话构建
        # dict2 = {'Status': 'Rollback', 'BUILD_ID': '26'} # 回滚
        return self.conn.build_job(job, parameters=kwargs)

    def get_build_info(self,job, build_number):
        """
        通过构建编号获取构建项目的构建记录
        :param job:
        :param build_number:
        :return:
        """
        return self.conn.get_build_info(job,build_number)

    def get_job_config(self,job):
        '''
        获取xml文件
        '''
        res = self.conn.get_job_config(job)
        print(res)

    def create_job(self,name,config_xml):
        '''
        任务名字
        xml格式的字符串
        '''
        self.conn.create_job(name, config_xml)

    def update_job(self,name,config_xml):
        res = self.conn.reconfig_job(name,config_xml)
        print(res)


if __name__ == '__main__':
    server_url = 'http://192.168.101.8:8888/'
    username = 'admin'
    password = '11217915472cb72a7edb9a4de8113a5928'
    server = Jenkinsapi(server_url, username, password)

    # jobs = server.get_jobs()
    # print(jobs)

    # job = server.get_job_info("project-1")
    # print(job)

    # build_number = server.build_job("project-1")
    # print(build_number)

    # info = server.get_build_info("project-1", 2)
    # print(info)

    # 先获取已有构建项目的配置文档
    # config_xml = server.get_job_config("project-1")
    # print(config_xml)

    config_xml = """<project>
<description>测试构建项目</description>
<keepDependencies>false</keepDependencies>
<properties/>
<scm class="hudson.scm.NullSCM"/>
<canRoam>true</canRoam>
<disabled>false</disabled>
<blockBuildWhenDownstreamBuilding>false</blockBuildWhenDownstreamBuilding>
<blockBuildWhenUpstreamBuilding>false</blockBuildWhenUpstreamBuilding>
<triggers/>
<concurrentBuild>false</concurrentBuild>
<builders>
<hudson.tasks.Shell>
  <command>echo "hello, project-2"</command>
  <configuredLocalRules/>
</hudson.tasks.Shell>
</builders>
<publishers/>
<buildWrappers/>
</project>"""
    server.create_job("project-2", config_xml=config_xml)

环境管理

由于我们进行代码发布的时候,需要选择环境(测试环境、线上环境等等),来区分我们本次将代码发布到什么环境的主机群组中。

所以我们先完成左侧菜单栏中环境管理。

image-20210120142832689

后端实现环境管理的API接口

创建应用

cd uric_api/apps
python ../../manage.py startapp conf_center

配置应用,settings/dev.py,代码:

INSTALLED_APPS = [
		...,
    'conf_center',
]

创建子应用路由文件,conf_center/urls.py,代码:

from django.urls import path
from . import views

urlpatterns = [

]

总路由,uric_api/urls,代码:

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('home/', include("home.urls")),
    path('host/', include("host.urls")),
    path('users/', include("users.urls")),
    path('mtask/', include('mtask.urls')),
    path('conf/', include('conf_center.urls')),
]

创建环境模型类,conf_center/models.py

from uric_api.utils.models import BaseModel,models
# Create your models here.
class Environment(BaseModel):
    tag = models.CharField(max_length=32,verbose_name='标识符')
    class Meta:
        db_table = "uc_environment"
        verbose_name = "环境配置"
        verbose_name_plural = verbose_name

终端下在项目根目录下执行数据迁移。

python manage.py makemigrations
python manage.py migrate

在mysql中,执行SQL语句, 添加测试数据。

INSERT INTO uric.environment (id, name, is_show, orders, is_deleted, created_time, updated_time, description, tag) VALUES (1, '测试环境', 1, 1, 0, '2022-08-18 00:43:09', '2022-08-18 00:43:09', null, 'dev');
INSERT INTO uric.environment (id, name, is_show, orders, is_deleted, created_time, updated_time, description, tag) VALUES (2, '运营环境', 1, 1, 0, '2022-08-18 00:43:09', '2022-08-18 00:43:09', null, 'prod');

扩展host子应用的主机模型的字段,添加上主机和环境的关系。

host/models.py

class Host(BaseModel):
    # 真正在数据库中的字段实际上叫 category_id,而category则代表了关联的哪个分类模型对象
    category = models.ForeignKey('HostCategory', on_delete=models.DO_NOTHING, verbose_name='主机类别', related_name='hc',
                                 null=True, blank=True)
    ip_addr = models.CharField(blank=True, null=True, max_length=500, verbose_name='连接地址')
    port = models.IntegerField(verbose_name='端口')
    username = models.CharField(max_length=50, verbose_name='登录用户')
    users = models.ManyToManyField(User)
    environment = models.ForeignKey(Environment, on_delete=models.DO_NOTHING, default=1, verbose_name='从属环境')

    class Meta:
        db_table = "host"
        verbose_name = "主机信息"
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.name + ':' + self.ip_addr

执行数据库数据迁移,同步数据结构:

python manage.py makemigrations
python manage.py migrate

conf_center/urls,路由代码:

from django.urls import path
from rest_framework.routers import DefaultRouter
from . import views

router = DefaultRouter()
router.register("env", views.EnvironmentAPIView, basename="env")

urlpatterns = [

] + router.urls

conf_center.views,视图代码:

from rest_framework.viewsets import ModelViewSet
from rest_framework.permissions import IsAuthenticated
from .models import Environment
from .serializers import EnvironmentModelSerializer
# Create your views here.


class EnvironmentAPIView(ModelViewSet):
    """
    环境管理的api接口
    """
    queryset = Environment.objects.all()
    serializer_class = EnvironmentModelSerializer
    permission_classes = [IsAuthenticated]

conf_center/serializers.py,序列化器代码:

from rest_framework import serializers
from .models import Environment


class EnvironmentModelSerializer(serializers.ModelSerializer):
    class Meta:
        model = Environment
        fields = ["id", "name", "tag", "description"]

前端实现环境管理的功能

src/views/Environment.vue,代码:

<template>
  <a-row>
    <a-col :span="20">
      <div class="add_host" style="margin: 15px;">
        <a-button @click="showEnvModal" type="primary">
          新建
        </a-button>
      </div>
    </a-col>
  </a-row>

  <a-table :dataSource="envList.data" :columns="envFormColumns">
    <template #bodyCell="{ column, text, record }">
      <template v-if="column.dataIndex === 'action'">
        <a-popconfirm
            v-if="envList.data.length"
            title="Sure to delete?"
            @confirm="deleteEnv(record)"
        >
          <a>Delete</a>
        </a-popconfirm>
        <a style="margin-left: 20px;" @click="showEnvUpdateModal(record)">Update</a>
      </template>
    </template>
  </a-table>

  <a-modal v-model:visible="envFormVisible" title="添加主机" @ok="onEnvFormSubmit" @cancel="resetForm()" :width="800">
    <a-form
        ref="formRef"
        name="custom-validation"
        :model="envForm.form"
        :rules="envForm.rules"
        v-bind="layout"
        @finish="handleFinish"
        @validate="handleValidate"
        @finishFailed="handleFinishFailed"
    >

      <a-form-item has-feedback label="环境名称" name="name">
        <a-input v-model:value="envForm.form.name" type="text" autocomplete="off"/>
      </a-form-item>

      <a-form-item has-feedback label="唯一标记符" name="tag">
        <a-input v-model:value="envForm.form.tag" type="text" autocomplete="off"/>
      </a-form-item>

      <a-form-item has-feedback label="备注信息" name="description">
        <a-textarea placeholder="请输入环境备注信息" v-model:value="envForm.form.description" type="text"
                    :auto-size="{ minRows: 3, maxRows: 5 }" autocomplete="off"/>
      </a-form-item>


      <a-form-item :wrapper-col="{ span: 14, offset: 4 }">
        <a-button @click="resetForm">Reset</a-button>
      </a-form-item>
    </a-form>


  </a-modal>
  <a-modal
      :width="600"
      title="新建主机类别"
      :visible="HostCategoryFromVisible"
      @cancel="hostCategoryFormCancel"
  >
    <template #footer>
      <a-button key="back" @click="hostCategoryFormCancel">取消</a-button>
      <a-button key="submit" type="primary" :loading="loading" @click="onHostCategoryFromSubmit">提交</a-button>
    </template>
    <a-form-model ref="hostCategoryRuleForm" v-model:value="hostCategoryForm.form" :rules="hostCategoryForm.rules"
                  :label-col="hostCategoryForm.labelCol" :wrapper-col="hostCategoryForm.wrapperCol">
      <a-form-model-item ref="name" label="类别名称" name="name">
        <a-row>
          <a-col :span="24">
            <a-input placeholder="请输入主机类别名称" v-model:value="hostCategoryForm.form.name"/>
          </a-col>
        </a-row>
      </a-form-model-item>
    </a-form-model>
  </a-modal>
  <!-- 批量导入主机 -->
  <div>
    <a-modal v-model:visible="excelVisible" title="导入excel批量创建主机" @ok="onExcelSubmit" @cancel="excelFormCancel"
             :width="800">
      <a-alert type="info" message="导入或输入的密码仅作首次验证使用,并不会存储密码。" banner closable/>
      <br/>

      <p>
        <a-form-item has-feedback label="模板下载" help="请下载使用该模板填充数据后导入">
          <a download="主机导入模板.xls">主机导入模板.xls</a>
        </a-form-item>
      </p>
      <p>
        <a-form-item label="默认密码"
                     help="如果Excel中密码为空则使用该密码">
          <a-input v-model:value="default_password" placeholder="请输入默认主机密码" type="password"/>
        </a-form-item>
      </p>
      <a-form-item label="导入数据">
        <div class="clearfix">
          <a-upload
              :file-list="fileList"
              name="file"
              :before-upload="beforeUpload"
          >
            <a-button>
              <upload-outlined></upload-outlined>
              Click to Upload
            </a-button>
          </a-upload>
        </div>
      </a-form-item>
    </a-modal>
    </div>

</template>
<script>
import {ref, reactive} from 'vue';
import axios from "axios";
import settings from "@/settings";
import store from "@/store";
import {message} from 'ant-design-vue';

export default {
  setup() {
    const formRef = ref();
    const HostCategoryFromVisible = ref(false);
    const envList = reactive({
      data: []
    })

    const envForm = reactive({
      labelCol: {span: 6},
      wrapperCol: {span: 14},
      other: '',
      form: {
        name: '',
        category: "",
        ip_addr: '',
        username: '',
        port: '',
        description: '',
        password: ''
      },
      rules: {
        name: [
          {required: true, message: '请输入环境名称', trigger: 'blur'},
          {min: 3, max: 30, message: '长度在3-10位之间', trigger: 'blur'}
        ],
        tag: [
          {required: true, message: '唯一标识符', trigger: 'blur'},
          {min: 3, max: 30, message: '长度在3-10位之间', trigger: 'blur'}
        ],
        description: [
          {required: true, message: '备注信息', trigger: 'blur'},
          {min: 1, max: 150, message: '长度在150位以内', trigger: 'blur'}
        ]
      }
    });

    const layout = {
      labelCol: {
        span: 4,
      },
      wrapperCol: {
        span: 14,
      },
    };

    const envFormColumns = [
        {
          title: 'ID',
          dataIndex: 'id',
          key: 'id',
          width: 100,
          sorter: {
            compare: (a, b) => a.id - b.id,
          },
        },
        {
          title: '环境名称',
          dataIndex: 'name',
          key: 'name',
          width: 200,
          sorter: {
            compare: (a, b) => a.name > b.name,
          },
        },
        {
          title: '唯一标记符',
          dataIndex: 'tag',
          key: 'tag',
          ellipsis: true,
          sorter: true,
          width: 200
        },
        {
          title: '备注',
          dataIndex: 'description',
          key: 'description',
          ellipsis: true
        },
        {
          title: '操作',
          key: 'action',
          width: 200,
          dataIndex: "action",
          scopedSlots: {customRender: 'action'}
        }
      ]

    const handleFinish = values => {
      console.log(values, envForm);
    };

    const handleFinishFailed = errors => {
      console.log(errors);
    };

    const resetForm = () => {
      formRef.value.resetFields();
    };

    const handleValidate = (...args) => {
      console.log(args);
    };

    const envFormVisible = ref(false);

    const showEnvModal = () => {
      envFormVisible.value = true;
    };


    // 提交添加环境的表单
    const onEnvFormSubmit = () => {
      // 将数据提交到后台进行保存,但是先进行连接校验,验证没有问题,再保存
      axios.post(`${settings.host}/conf/env/`, envForm.form, {
            headers: {
              Authorization: "jwt " + store.getters.token,
            }
          }
      ).then((response) => {
        console.log("response>>>", response)
        envList.data.unshift(response.data)
        // 清空
        resetForm()
        envFormVisible.value = false; // 关闭对话框
        message.success('成功添加主机信息!')

      }).catch((err) => {
        message.error('添加主机失败')
      });
    }

    const deleteEnv = record => {
      axios.delete(`${settings.host}/conf/env/${record.id}`, {
        headers: {
          Authorization: "jwt " + store.getters.token
        }
      }).then(response => {
        let index = envList.data.indexOf(record)
        envList.data.splice(index, 1);
      }).catch(err => {
        message.error('删除环境失败!')
      })
    }


    const get_env_list = () => {
      // 获取环境列表

      axios.get(`${settings.host}/conf/env`, {
        headers: {
          Authorization: "jwt " + store.getters.token
        }
      }).then(response => {
        envList.data = response.data
        console.log("envList.data=", envList.data)
      }).catch(err => {
        message.error('无法获取环境列表信息!')
      })
    }

    // 获取环境列表
    get_env_list()

    // 更新环境信息
    const showEnvUpdateModal = ()=>{

    }

    return {
      envForm,
      formRef,
      layout,
      HostCategoryFromVisible,
      handleFinishFailed,
      handleFinish,
      resetForm,
      handleValidate,
      envFormVisible,
      showEnvModal,
      onEnvFormSubmit,
      deleteEnv,
      envFormColumns,
      envList,
      showEnvUpdateModal,
    };
  },
};
</script>

路由,src/router/index.js,代码:

import {createRouter, createWebHistory} from 'vue-router'
import ShowCenter from '../views/ShowCenter.vue'
import Login from '../views/Login.vue'
import Base from '../views/Base'
import Host from '../views/Host'
import Console from '../views/Console'
import MultiExec from '../views/MultiExec'
import Environment from '../views/Environment'
import store from "../store"

const routes = [
    {
        path: '/uric',
        alias: '/', // 给当前路径起一个别名
        name: 'Base',
        component: Base, // 快捷键:Alt+Enter快速导包
        children: [
            {
                meta: {
                    title: '展示中心',
                    authenticate: false,
                },
                path: 'show_center',
                alias: '',
                name: 'ShowCenter',
                component: ShowCenter
            },
            {
                meta: {
                    title: '资产管理',
                    authenticate: true,
                },
                path: 'host',
                name: 'Host',
                component: Host
            },
            {
                meta: {
                    title: 'Console',
                    authenticate: true,
                },
                path: 'console/:host_id',
                name: 'Console',
                component: Console
            },
            {
                path: 'multi_exec',
                name: 'MultiExec',
                component: MultiExec,
            },
            {
                path: 'environment',
                name: 'Environment',
                component: Environment,
            }
        ]
    },


    {
        meta: {
            title: '账户登陆',
            authenticate: false,
        },
        path: '/login',
        name: 'Login',
        component: Login // 快捷键:Alt+Enter快速导包
    },
];

const router = createRouter({
    history: createWebHistory(process.env.BASE_URL),
    routes
})

router.beforeEach((to, from, next) => {
    document.title = to.meta.title;
    // console.log("to", to)
    // console.log("from", from)
    // console.log("store.getters.token:", store.getters.token)
    if (to.meta.authenticate && store.getters.token === "") {
        next({name: "Login"})
    } else {
        next()
    }
});

export default router

添加主机时设置当前主机的从属环境

src/views/Host.vue,在添加主机的a-modal组件中新增从属环境的字段,代码:

<template>
  // 中间代码省略。。。。
  <a-modal v-model:visible="hostFormVisible" title="添加主机" @ok="onHostFormSubmit" @cancel="resetForm()" :width="800">
    <a-form
        ref="formRef"
        name="custom-validation"
        :model="hostForm.form"
        :rules="hostForm.rules"
        v-bind="layout"
        @finish="handleFinish"
        @validate="handleValidate"
        @finishFailed="handleFinishFailed"
    >
      <a-form-item label="主机类别" name="category">
        <a-row>
          <a-col :span="12">
            <a-select
                ref="select"
                v-model:value="hostForm.form.category"
                @change="handleCategorySelectChange"
            >
              <a-select-option :value="category.id" v-for="category in categoryList.data" :key="category.id">
                {{ category.name }}
              </a-select-option>
            </a-select>
          </a-col>
          <a-button style="margin-left: 10px;" @click="showHostCategoryFormModal">添加类别</a-button>
        </a-row>
      </a-form-item>
      <a-form-item label="从属环境" name="environment">
        <a-row>
          <a-col :span="12">
            <a-select
                ref="select"
                v-model:value="hostForm.form.environment"
                @change="handleEnvironmentSelectChange"
            >
              <a-select-option :value="environment.id" v-for="environment in environmentList.data" :key="environment.id">
                {{ environment.name }}
              </a-select-option>
            </a-select>
          </a-col>
        </a-row>
      </a-form-item>
      <a-form-item has-feedback label="主机名称" name="name">
        <a-input v-model:value="hostForm.form.name" type="text" autocomplete="off"/>
      </a-form-item>


      <a-form-item has-feedback label="连接地址" name="username">
        <a-row>
          <a-col :span="8">
            <a-input placeholder="用户名" addon-before="ssh" v-model:value="hostForm.form.username" type="text"
                     autocomplete="off"/>
          </a-col>
          <a-col :span="8">
            <a-input placeholder="ip地址" addon-before="@" v-model:value="hostForm.form.ip_addr" type="text"
                     autocomplete="off"/>
          </a-col>
          <a-col :span="8">
            <a-input placeholder="端口号" addon-before="-p" v-model:value="hostForm.form.port" type="text"
                     autocomplete="off"/>
          </a-col>
        </a-row>
      </a-form-item>

      <a-form-item has-feedback label="连接密码" name="password">
        <a-input v-model:value="hostForm.form.password" type="password" autocomplete="off"/>
      </a-form-item>

      <a-form-item has-feedback label="备注信息" name="description">
        <a-textarea placeholder="请输入主机备注信息" v-model:value="hostForm.form.description" type="text"
                    :auto-size="{ minRows: 3, maxRows: 5 }" autocomplete="off"/>
      </a-form-item>

      <a-form-item :wrapper-col="{ span: 14, offset: 4 }">
        <a-button @click="resetForm">Reset</a-button>
      </a-form-item>
    </a-form>
  </a-modal>
  // 中间代码省略。。。。
</template>
<script>
  // 中间代码省略。。。。

export default {
  // 中间代码省略。。。。
  setup() {
  // 中间代码省略。。。。
    const handleEnvironmentSelectChange = (value) => {
      // 切换环境的回调处理
      console.log(value)
    }

    const environmentList = reactive({  // 新增环境列表
      data: []
    })

    const hostForm = reactive({
      labelCol: {span: 6},
      wrapperCol: {span: 14},
      other: '',
      form: {
        name: '',
        category: "",
        environment: "",  // 新增环境字段
        ip_addr: '',
        username: '',
        port: '',
        description: '',
        password: ''
      },
      rules: {
        name: [
          {required: true, message: '请输入主机名称', trigger: 'blur'},
          {min: 3, max: 30, message: '长度在3-10位之间', trigger: 'blur'}
        ],
        password: [
          {required: true, message: '请输入连接密码', trigger: 'blur'},
          {min: 3, max: 30, message: '长度在3-10位之间', trigger: 'blur'}
        ],
        category: [
          {required: true, message: '请选择类别', trigger: 'change'}
        ],
        environment: [ // 新增环境字段的校验代码
          {required: true, message: '请选择环境', trigger: 'change'}
        ],
        username: [
          {required: true, message: '请输入用户名', trigger: 'blur'},
          {min: 3, max: 30, message: '长度在3-10位', trigger: 'blur'}
        ],
        ip_addr: [
          {required: true, message: '请输入连接地址', trigger: 'blur'},
          {max: 30, message: '长度最大15位', trigger: 'blur'}
        ],
        port: [
          {required: true, message: '请输入端口号', trigger: 'blur'},
          {max: 5, message: '长度最大5位', trigger: 'blur'}
        ]
      }
    });

   // 中间代码省略。。。。

    const hostFormColumns = [
        {
          title: '类别',
          dataIndex: 'category_name',
          key: 'category_name'
        },
        {
          title: '环境',  // 主机列表的表格中新增从属环境字段
          dataIndex: 'environment_name',
          key: 'environment_name'
        },
        {
          title: '主机名称',
          dataIndex: 'name',
          key: 'name',
          sorter: true,
          width: 230
        },
        {
          title: '连接地址',
          dataIndex: 'ip_addr',
          key: 'ip_addr',
          ellipsis: true,
          sorter: true,
          width: 150
        },
        {
          title: '端口',
          dataIndex: 'port',
          key: 'port',
          ellipsis: true
        },
        {
          title: 'Console',
          dataIndex: 'console',
          key: 'console',
          ellipsis: true
        },

        {
          title: '操作',
          key: 'action',
          width: 200,
          dataIndex: "action",
          scopedSlots: {customRender: 'action'}
        }
      ]

    // 中间代码省略。。。。 
    
    const get_environment_list = () => {
      // 获取主机类别列表
      axios.get(`${settings.host}/conf/env`, {
        headers: {
          Authorization: "jwt " + store.getters.token
        }
      }).then(response => {
        environmentList.data = response.data
      }).catch(err => {
        message.error('无法获取环境列表信息!')
      })
    }
    // 获取环境列表
    get_environment_list()

    // 中间代码省略。。。。
      
    return {
      // 中间代码省略。。。。
      handleEnvironmentSelectChange,
      environmentList,
    };
  },
};
</script>

服务端代码中在添加主机时接受环境id以及在查询主机列表时新增返回环境字段给客户端。

host/serializers.py,代码:

class HostModelSerializers(serializers.ModelSerializer):
    """主机信息的序列化器"""
    category_name = serializers.CharField(source='category.name', read_only=True)
    environment_name = serializers.CharField(source='environment.name', read_only=True)
    password = serializers.CharField(max_length=32, write_only=True, label="登录密码")

    class Meta:
        model = models.Host
        fields = ['id', 'category', "category_name", 'environment', "environment_name", 'name', 'ip_addr', 'port', 'description', 'username', 'password', "description"]

应用管理

所谓的应用,就是项目的代码(项目,更具体就是git仓库里面的提交代码版本历史)与数据(软件,mysql中的数据表)。

服务端实现

创建代码发布功能的应用

cd uric_api/apps
python ../../manage.py startapp release

配置应用,settings/dev.py,代码:

INSTALLED_APPS = [
    ...,
    'release',
]

创建子应用release的路由文件,release.urls,代码:

from django.urls import path
from . import views

urlpatterns = [

]

总路由,uric_api.urls,代码:

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('home/', include("home.urls")),
    path('host/', include("host.urls")),
    path('users/', include("users.urls")),
    path('mtask/', include('mtask.urls')),
    path('conf/', include('conf_center.urls')),
    path('release/', include('release.urls')),
]

创建应用模型类

releaseApp/models.py

from uric_api.utils.models import BaseModel, models
from users.models import User


# Create your models here.
class ReleaseApp(BaseModel):
    tag = models.CharField(max_length=32, unique=True, verbose_name='应用唯一标识号')
    user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='用户')

    class Meta:
        db_table = "release_app"
        verbose_name = "应用管理"
        verbose_name_plural = verbose_name

数据迁移,项目根目录下,执行

python manage.py makemigrations
python manage.py migrate

release.urls,路由,代码:

from django.urls import path
from . import views

from rest_framework.routers import DefaultRouter

router = DefaultRouter()
router.register("app", views.ReleaseAPIView, "app")

urlpatterns = [

] + router.urls

release.views,视图,代码:

from rest_framework.viewsets import ModelViewSet
from rest_framework.permissions import IsAuthenticated
from .models import ReleaseApp
from .serializers import ReleaseAppModelSerializer


class ReleaseAPIView(ModelViewSet):
    queryset = ReleaseApp.objects.all()
    serializer_class = ReleaseAppModelSerializer
    permission_classes = [IsAuthenticated]

release.serailizers,代码:

from rest_framework import serializers
from .models import ReleaseApp


class ReleaseAppModelSerializer(serializers.ModelSerializer):
    """发布应用的序列化器"""
    class Meta:
        model = ReleaseApp
        fields = ["id", "name", "tag", "description"]

    def create(self, validated_data):
        """添加"""
        print("self.context", self.context)
        # self.context = {"request": request, "view": view, "format": format}
        validated_data["user_id"] = self.context["request"].user.id
        return super().create(validated_data)

mysql执行SQL语句,添加测试数据。

INSERT INTO uric.release_app (id, name, is_show, orders, is_deleted, created_time, updated_time, description, tag, user_id) VALUES (1, '购物车', 1, 1, 0, '2021-08-08 01:50:04', '2021-08-08 01:50:04', null, 'cart', 1);
INSERT INTO uric.release_app (id, name, is_show, orders, is_deleted, created_time, updated_time, description, tag, user_id) VALUES (2, '支付模块', 1, 1, 0, '2021-08-08 01:50:04', '2021-08-08 01:50:04', null, 'pay', 1);
INSERT INTO uric.release_app (id, name, is_show, orders, is_deleted, created_time, updated_time, description, tag, user_id) VALUES (3, '商品模块', 1, 1, 0, '2021-08-08 01:50:04', '2021-08-08 01:50:04', null, 'goods', 1);

客户端实现

客户端新建发布应用的组件,src/views/Release.vue,代码:

<template>
  <div class="search" style="margin-top: 15px;">
    <a-row>
      <a-col :span="8">
        <a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol" label="应用名称:">
          <a-input placeholder="请输入"/>
        </a-form-item>
      </a-col>
      <a-col :span="8">
        <a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol" label="描述信息:">
          <a-input placeholder="请输入"/>
        </a-form-item>
      </a-col>
      <a-col :span="8">
        <router-link to="/release">
          <a-button type="primary" style="margin-top: 3px;">刷新</a-button>
        </router-link>
      </a-col>
    </a-row>
  </div>

  <div class="add_app">
    <a-button style="margin-bottom: 20px;" @click="showAppModal">新建应用</a-button>
  </div>
  <a-modal v-model:visible="AppModelVisible" title="新建应用" @ok="handleaddappOk" ok-text="添加" cancel-text="取消">
    <a-form ref="addappruleForm" :model="app_form" :rules="add_app_rules" :label-col="labelCol" :wrapper-col="wrapperCol">
      <a-form-item ref="app_name" label="应用名称" prop="app_name">
        <a-input v-model:value="app_form.app_name"/>
      </a-form-item>
      <a-form-item ref="tag" label="唯一标识符" prop="tag"><a-input v-model:value="app_form.tag"/>
      </a-form-item>
      <a-form-item label="备注信息" prop="app_desc">
        <a-textarea v-model:value="app_form.app_desc"/>
      </a-form-item>
    </a-form>
  </a-modal>

  <div class="release">
    <div class="app_list">
      <a-table :columns="columns" :data-source="releaseAppList" row-key="id">
        <template #bodyCell="{ column, text, record }">
          <template v-if="column.dataIndex === 'action'">
            <a>新建发布</a>
            <span style="color: lightgray"> | </span>
            <a>克隆发布</a>
            <span style="color: lightgray"> | </span>
            <a>编辑</a>
            <span style="color: lightgray"> | </span>
            <a>删除</a>
          </template>
        </template>
      </a-table>
    </div>
  </div>
</template>

<script>
import {ref, reactive} from 'vue';
import axios from "axios";
import settings from "@/settings";
import {message} from 'ant-design-vue';
import store from "@/store";

export default {
  setup() {
    // 搜索栏的表单布局设置
    const formItemLayout = reactive({
      labelCol: {span: 8},
      wrapperCol: {span: 12},
    });

    // 表格字段列设置
    const columns = [
      {
        title: '应用名称',
        dataIndex: 'name',
        key: 'name',
        sorter: true,
        width: 230
      },
      {
        title: '标识符',
        dataIndex: 'tag',
        key: 'tag',
        sorter: true,
        width: 150
      },
      {
        title: '描述信息',
        dataIndex: 'description',
        key: 'description'
      },
      {
        title: '操作',
        dataIndex: 'action',
        width: 300,
        key: 'action', scopedSlots: {customRender: 'action'}
      },
    ]

    // 发布应用列表
    const releaseAppList = ref([])

    // 获取发布应用列表
    const get_release_app_list = ()=>{
      axios.get(`${settings.host}/release/app/`,{
        headers: {
          Authorization: "jwt " + store.getters.token,
        }
      })
      .then(response=>{
        releaseAppList.value = response.data;
      }).catch((error)=>{
        message.error(error.response.data)
      })
    }

    get_release_app_list()

    // 是否显示新建发布应用的弹窗
    const AppModelVisible = ref(false)

    const showAppModal = ()=>{
      AppModelVisible.value = true;
    }

    const labelCol = reactive({
      span: 6
    })

    const wrapperCol = reactive({
      span: 16
    })


    const app_form = reactive({               // 新建发布应用的表单数据
        app_name: '',
        tag: '',
        app_desc: '',
    })

    const add_app_rules = reactive({  // 添加发布应用的表单数据验证规则
        app_name: [
          {required: true, message: '请输入应用名称', trigger: 'blur'},
          {min: 1, max: 30, message: '应用名称的长度必须在1~30个字符之间', trigger: 'blur'},
        ],
        tag: [
          {required: true, message: '请输入应用唯一标识符', trigger: 'blur'},
          {min: 1, max: 50, message: '应用名称的长度必须在1~50个字符之间', trigger: 'blur'},
        ],
    })

    const handleaddappOk = ()=>{
      // 添加应用的表单提交处理
      
    }

    return {
      formItemLayout,

      columns,
      releaseAppList,

      AppModelVisible,
      showAppModal,
      app_form,
      labelCol,
      wrapperCol,
      add_app_rules,
    }
  }
}
</script>


<style scoped>
.release_btn span{
  color: #1890ff;
  cursor: pointer;
}
</style>

添加发布应用的表单数据提交处理

<template>
  <div class="release">
    <div class="search">
      <a-row>
        <a-col :span="8">
          <a-form-item
            :label-col="formItemLayout.labelCol"
            :wrapper-col="formItemLayout.wrapperCol"
            label="应用名称:"
          >
            <a-input
              placeholder="请输入"
            />
          </a-form-item>
        </a-col>
        <a-col :span="8">
          <a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol" label="描述信息:">
            <a-input placeholder="请输入"/>
          </a-form-item>
        </a-col>
        <a-col :span="8">
          <router-link to="/release">
            <a-button type="primary" style="margin-top: 3px;">
              刷新
            </a-button>
          </router-link>
        </a-col>
      </a-row>
    </div>
    <div class="add_app">
      <a-button style="margin-bottom: 20px;" @click="showAppModal">新建应用</a-button>
      <a-modal v-model:visible="AppModelVisible" title="新建应用" @ok="handleaddappOk" ok-text="添加" cancel-text="取消">
        <a-form ref="addappruleForm" :model="app_form" :rules="add_app_rules" :label-col="labelCol" :wrapper-col="wrapperCol">
          <a-form-item ref="app_name" label="应用名称" prop="app_name">
            <a-input v-model:value="app_form.app_name"/>
          </a-form-item>
          <a-form-item ref="tag" label="唯一标识符" prop="tag"><a-input v-model:value="app_form.tag"/>
          </a-form-item>
          <a-form-item label="备注信息" prop="app_desc">
            <a-textarea v-model:value="app_form.app_desc"/>
          </a-form-item>
        </a-form>
      </a-modal>
    </div>
    <div class="app_list">
      <a-table :columns="columns" :data-source="app_data" row-key="id">
        <template #bodyCell="{ column, text, record }">
          <template v-if="column.dataIndex === 'action'">
            <a @click="showModal(record.id)">新建发布</a>
            <span style="color: lightgray"> | </span>
            <a>克隆发布</a>
            <span style="color: lightgray"> | </span>
            <a>编辑</a>
            <span style="color: lightgray"> | </span>
            <a>删除</a>
          </template>
        </template>
      </a-table>

    </div>
  </div>
</template>

<script>
import {ref, reactive} from 'vue';
import axios from "axios";
import settings from "@/settings";
import {message} from 'ant-design-vue';
import store from "@/store";

export default {
  setup() {
    const columns = ref([
      {
        title: '应用名称',
        dataIndex: 'name',
        key: 'name',
        sorter: true,
        width: 230
      },
      {
        title: '标识符',
        dataIndex: 'tag',
        key: 'tag',
        sorter: true,
        width: 150
      },
      {
        title: '描述信息',
        dataIndex: 'description',
        key: 'description'
      },
      {
        title: '操作',
        dataIndex: 'action',
        width: 300,
        key: 'action', scopedSlots: {customRender: 'action'}
      },
    ]);

    const formItemLayout = reactive({
      labelCol: {span: 8},
      wrapperCol: {span: 12},
    });

    const labelCol = reactive({
      span: 6
    })

    const wrapperCol = reactive({
      span: 16
    })

    // 应用列表
    const app_data = ref([])

    // 发布环境数据
    const env_data = ref([])

    // 是否显示新建发布应用的弹窗
    const AppModelVisible = ref(false)

    const app_form = reactive({               // 新建发布应用的表单数据
        app_name: '',
        tag: '',
        app_desc: '',
    })

    const add_app_rules = reactive({  // 添加发布应用的表单数据验证规则
        app_name: [
          {required: true, message: '请输入应用名称', trigger: 'blur'},
          {min: 1, max: 30, message: '应用名称的长度必须在1~30个字符之间', trigger: 'blur'},
        ],
        tag: [
          {required: true, message: '请输入应用唯一标识符', trigger: 'blur'},
          {min: 1, max: 50, message: '应用名称的长度必须在1~50个字符之间', trigger: 'blur'},
        ],
    })

    const get_release_app_data = ()=>{
      let token = sessionStorage.token || localStorage.token;
      axios.get(`${settings.host}/release/app`,{
        headers: {
          Authorization: "jwt " + store.getters.token,
        }
      })
      .then(response=>{
        app_data.value = response.data;
      }).catch((error)=>{
        message.error(error.response.data)
      })
    }

    get_release_app_data()

    // 显示添加应用窗口
    const showAppModal = ()=>{
      AppModelVisible.value = true;
    }

    const handleaddappOk = (e)=>{
      let data = {
          name: app_form.app_name,
          tag: app_form.tag,
          description: app_form.app_desc,
        }
        let token = sessionStorage.token || localStorage.token;
        axios.post(`${settings.host}/release/app`,data,{
          headers: {
            Authorization: "jwt " + store.getters.token,
          }
        })
        .then((res)=>{
          app_data.value.push(res.data);
          message.success('添加成功!');
          AppModelVisible.value = false;
        }).catch((error)=>{
          message.error('添加失败!');
        })
    }

    const showModal = (record_id)=>{
      console.log(record_id)
    }

    return {
      columns,
      formItemLayout,
      labelCol,
      wrapperCol,
      app_data,
      env_data,
      AppModelVisible,
      app_form,
      add_app_rules,
      get_release_app_data,
      showAppModal,
      handleaddappOk,
      showModal,
    }
  }
}
</script>


<style scoped>
.release_btn span{
  color: #1890ff;
  cursor: pointer;
}
</style>

路由,代码:

import {createRouter, createWebHistory} from 'vue-router'
import ShowCenter from '../views/ShowCenter.vue'
import Login from '../views/Login.vue'
import Base from '../views/Base'
import Host from '../views/Host'
import Console from '../views/Console'
import MultiExec from '../views/MultiExec'
import Environment from '../views/Environment'
import Release from '../views/Release'
import store from "../store"

const routes = [
    {
        path: '/uric',
        alias: '/', // 给当前路径起一个别名
        name: 'Base',
        component: Base, // 快捷键:Alt+Enter快速导包
        children: [
            {
                meta: {
                    title: '展示中心',
                    authenticate: false,
                },
                path: 'show_center',
                alias: '',
                name: 'ShowCenter',
                component: ShowCenter
            },
            {
                meta: {
                    title: '资产管理',
                    authenticate: true,
                },
                path: 'host',
                name: 'Host',
                component: Host
            },
            {
                meta: {
                    title: 'Console',
                    authenticate: true,
                },
                path: 'console/:host_id',
                name: 'Console',
                component: Console
            },
            {
                path: 'multi_exec',
                name: 'MultiExec',
                component: MultiExec,
            },
            {
                path: 'environment',
                name: 'Environment',
                component: Environment,
            },
            {
                path: 'release',
                name: 'Release',
                component: Release,
            }
        ]
    },


    {
        meta: {
            title: '账户登陆',
            authenticate: false,
        },
        path: '/login',
        name: 'Login',
        component: Login // 快捷键:Alt+Enter快速导包
    },
];

const router = createRouter({
    history: createWebHistory(process.env.BASE_URL),
    routes
})

router.beforeEach((to, from, next) => {
    document.title = to.meta.title;
    // console.log("to", to)
    // console.log("from", from)
    // console.log("store.getters.token:", store.getters.token)
    if (to.meta.authenticate && store.getters.token === "") {
        next({name: "Login"})
    } else {
        next()
    }
});

export default router


搜索应用功能实现

release/views.py,代码:

from rest_framework.viewsets import ModelViewSet
from rest_framework.permissions import IsAuthenticated
from .models import ReleaseApp
from .serializers import ReleaseAppModelSerializer


class ReleaseAPIView(ModelViewSet):
    serializer_class = ReleaseAppModelSerializer
    permission_classes = [IsAuthenticated]

    def get_queryset(self):
        queryset = ReleaseApp.objects
        app_name = self.request.query_params.get("app_name", None)
        description = self.request.query_params.get("description", None)
        tag = self.request.query_params.get("tag", None)
        if app_name:
            queryset = queryset.filter(name__contains=app_name)
        if description:
            queryset = queryset.filter(description__contains=description)
        if tag:
            queryset = queryset.filter(tag__contains=tag)

        return queryset.all()

客户端实现搜索应用功能,views/Release.vue,代码:

<template>
  <div class="search" style="margin-top: 15px;">
    <a-row>
      <a-col :span="6">
        <a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol" label="应用名称:">
          <a-input v-model:value="searchForm.app_name" placeholder="请输入"/>
        </a-form-item>
      </a-col>
      <a-col :span="6">
        <a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol" label="标记符:">
          <a-input v-model:value="searchForm.tag" placeholder="请输入"/>
        </a-form-item>
      </a-col>
      <a-col :span="6">
        <a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol" label="描述信息:">
          <a-input v-model:value="searchForm.description" placeholder="请输入"/>
        </a-form-item>
      </a-col>
      <a-col :span="6">
        <router-link to="/release">
          <a-button type="primary" style="margin-top: 3px;">刷新</a-button>
        </router-link>
      </a-col>
    </a-row>
  </div>

  // 中间代码省略。。。。

</template>
<script>
import {ref, reactive, watch} from 'vue';
import axios from "axios";
import settings from "@/settings";
import {message} from 'ant-design-vue';
import store from "@/store";

export default {
  setup() {
    // 搜索栏的表单布局设置
    const formItemLayout = reactive({
      labelCol: {span: 8},
      wrapperCol: {span: 12},
    });

    // 中间代码省略。。。。
    
    // 应用搜索
    const searchForm = reactive({
      app_name: "",
      description: "",
      tag: "",
    })

    // 监听搜索框的输入内容
    watch(
        searchForm,
        ()=>{
          get_release_app_list(searchForm)
        }
    )

    return {
      // 中间代码省略。。。。
      searchForm,
    }
  }
}
</script>

代码发布

代码发布流程中,需要管理gitlab的仓库,所以我们需要把gitlab的仓库地址与发布应用结合。

Gitlab仓库管理

Gitlab仓库列表

utils/gitlabapi.py,代码:

import gitlab
from django.conf import settings

class GitlabApi(object):
    VISIBILITY = {
        "private": "私有",
        "internal": "内部",
        "public": "公开"
    }

    def __init__(self, url=None, token=None):
        if url is None:
            url = settings.GITLAB.get("url")
        if token is None:
            token = settings.GITLAB.get("token")

        self.url = url
        self.token = token
        self.conn = gitlab.Gitlab(self.url, self.token)

    def get_projects(self):
        """
        获取所有的项目
        :return:
        """
        projects = self.conn.projects.list(all=True, iterator=True)
        projectslist = []
        for pro in projects:
            projectslist.append(pro.attributes)  # pro.attributes 项目的所有属性
        return projectslist

    def get_projects_visibility(self, visibility="public"):
        """
        根据可见性属性获取项目
        :param visibility:
        :return:
        """
        if visibility in self.VISIBILITY:
            attribute = visibility
        else:
            attribute = "public"
        projects = self.conn.projects.list(all=True, visibility=attribute)
        projectslist = []
        for pro in projects:
            projectslist.append(pro.attributes)
        return projectslist

    def get_projects_id(self, project_id):
        """
        根据id获取项目
        :param project_id:
        :return:
        """
        res = self.conn.projects.get(project_id)
        return res.attributes

    def get_projects_search(self, name):
        """
        模糊搜索项目
        :param name:
        :return:
        """
        projects = self.conn.projects.list(search=name)
        projectslist = []
        for pro in projects:
            projectslist.append(pro.attributes)
        return projectslist

    def create_project(self, data):
        """
        创建项目
        :param data:
        :return:
        """
        res = self.conn.projects.create(data)
        return res.attributes

    def get_project_brances(self, project_id):
        """
        获取项目所有分支
        :param project_id:
        :return:
        """
        project = self.conn.projects.get(project_id)
        brancheslist = []
        for branches in project.branches.list():
            brancheslist.append(branches.attributes)
        return brancheslist

    def get_project_brance_attribute(self, project_id, branch):
        """
        获取指定项目指定分支
        :param project_id:
        :param branch:
        :return:
        """
        project = self.conn.projects.get(project_id)
        res = project.branches.get(branch)
        return res.attributes

    def create_get_project_brance(self, project_id, branch, ref="main"):
        """
        创建分支
        :param project_id:
        :param branch:
        :param ref:
        :return:
        """
        project = self.conn.projects.get(project_id)
        res = project.branches.create({"branch": branch, "ref": ref})
        return res.attributes

    def delete_project_brance(self, project_id, branch):
        """
        删除分支
        :param project_id:
        :param branch:
        :return:
        """
        project = self.conn.projects.get(project_id)
        project.branches.delete(branch)

    def protect_project_brance(self, project_id, branch, is_protect=None):
        """
        分支保护[v3.0可用, V4.0不可用]
        :param project_id:
        :param branch:
        :param is_protect:
        :return:
        """
        project = self.conn.projects.get(project_id)
        branch = project.branches.get(branch)
        if is_protect == "protect":
            branch.unprotect()
        else:
            branch.protect()

    def get_project_tags(self, project_id):
        """
        获取所有的tags标签
        :param project_id:
        :return:
        """
        project = self.conn.projects.get(project_id)
        tags = project.tags.list()
        taglist = []
        for tag in tags:
            taglist.append(tag.attributes)
        return taglist

    def get_project_tag_name(self, project_id, name):
        """
        获取指定的tag
        :param project_id:
        :param name:
        :return:
        """
        project = self.conn.projects.get(project_id)
        tags = project.tags.get(name)
        return tags.attributes

    def create_project_tag(self, project_id, name, branch="master"):
        """
        创建tag
        :param project_id:
        :param name:
        :param branch:
        :return:
        """
        project = self.conn.projects.get(project_id)
        tags = project.tags.create({"tag_name": name, "ref": branch})
        return tags.attributes

    def delete_project_tag(self, project_id, name):
        """
        删除tags
        :param project_id:
        :param name:
        :return:
        """
        project = self.conn.projects.get(project_id)
        project.tags.delete(name)

    def get_project_commits(self, project_id):
        """
        获取所有的commit
        :param project_id:
        :return:
        """
        project = self.conn.projects.get(project_id)
        commits = project.commits.list()
        commitslist = []
        for com in commits:
            commitslist.append(com.attributes)
        return commitslist

    def get_project_commit_info(self, project_id, commit_id):
        """
        获取指定的commit
        :param project_id:
        :param commit_id:
        :return:
        """
        project = self.conn.projects.get(project_id)
        commit = project.commits.get(commit_id)
        return commit.attributes

    def get_project_merge(self, project_id):
        """
        获取所有的合并请求
        :param project_id:
        :return:
        """
        project = self.conn.projects.get(project_id)
        mergerquests = project.mergerequests.list()
        mergerquestslist = []
        for mergerquest in mergerquests:
            mergerquestslist.append(mergerquest.attributes)
        return mergerquestslist

    def get_project_merge_id(self, project_id, mr_id):
        """
        获取请求的详细信息
        :param project_id:
        :param mr_id:
        :return:
        """
        project = self.conn.projects.get(project_id)
        mrinfo = project.mergerequests.get(mr_id)
        return mrinfo.attributes

    def create_project_merge(self, project_id, source_branch, target_branch, title):
        """
        创建合并请求
        :param project_id:
        :param source_branch:
        :param target_branch:
        :param title:
        :return:
        """
        project = self.conn.projects.get(project_id)
        res = project.mergerequests.create(
            {"source_branch": source_branch, "target_branch": target_branch, "title": title})
        return res

    def update_project_merge_info(self, project_id, mr_id, data):
        """
        更新合并请求的信息
        :param project_id:
        :param mr_id:
        :param data:
        :return:
        """
        # data = {"description":"new描述","state_event":"close"}
        project = self.conn.projects.get(project_id)
        mr = project.mergerequests.get(mr_id)
        if "description" in data:
            mr.description = data["description"]
        if "state_event" in data:
            state_event = ["close", "reopen"]
            if data["state_event"] in state_event:
                mr.state_event = data["state_event"]
        res = mr.save()
        return res

    def delete_project_merge(self, project_id, mr_id):
        """
        删除合并请求
        :param project_id:
        :param mr_id:
        :return:
        """
        project = self.conn.projects.get(project_id)
        res = project.mergerequests.delete(mr_id)
        return res

    def access_project_merge(self, project_id, mr_id):
        """
        允许合并请求
        :param project_id:
        :param mr_id:
        :return:
        """
        project = self.conn.projects.get(project_id)
        mr = project.mergerequests.get(mr_id)
        res = mr.merge()
        return res

    def search_project_merge(self, project_id, state, sort):
        '''
        搜索项目合并请求
        :param project_id:
        :param state: state of the mr,It can be one of all,merged,opened or closed
        :param sort: sort order (asc or desc)
        :return:
        '''
        stateinfo = ["merged", "opened", "closed"]
        sortinfo = ["asc", "desc"]
        if state not in stateinfo:
            state = "merged"
        if sort not in sortinfo:
            sort = "asc"
        project = self.conn.projects.get(project_id)
        mergerquests = project.mergerequests.list(state=state, sort=sort)
        mergerquestslist = []
        for mergerquest in mergerquests:
            mergerquestslist.append(mergerquest.attributes)
        return mergerquestslist

    def create_project_commit(self, project_id, branch_name, message, actions):
        """
        创建项目提交记录
        :param project_id:
        :param branch_name:
        :param message:
        :param actions:
        :return:
        """
        project = self.conn.projects.get(project_id)
        data = {
            'branch': branch_name,
            'commit_message': message,
            'actions': actions,
            # 'actions': [{
            #     'action': 'create',
            #     'file_path': 'myreadme',
            #     'contend': 'commit_test'
            # }]
        }
        commit = project.commits.create(data)
        return commit

    def diff_project_branches(self, project_id, source_branch, target_branch):
        """
        比较2个分支
        :param project_id:
        :param source_branch:
        :param target_branch:
        :return:
        """
        project = self.conn.projects.get(project_id)
        result = project.repository_compare(source_branch, target_branch)
        # commits = result["commits"]
        # commits = result["diffs"]
        return result

settings/dev.py,代码:

GITLAB = {
    "url": "http://192.168.101.8:8993/",
    "token": "LAgbKLyaysE4UjPyX1EV",
}

release/views.py,代码:

from rest_framework.viewsets import ViewSet
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from uric_api.utils.gitlabapi import GitlabApi
from rest_framework.decorators import action

class GitlabAPIView(ViewSet):
    """gitlab仓库的管理"""
    git = GitlabApi()
    permission_classes = [IsAuthenticated]
    def list(self, request):
        """获取所有仓库列表"""
        return Response(self.git.get_projects())

    @action(methods=["GET"], detail=True)
    def branchs(self, request, pk):
        """获取指定项目的分支列表"""
        return Response(self.git.get_project_brances(pk))

    @action(methods=["GET"], detail=True)
    def commits(self, request, pk):
        """获取指定项目的commit历史版本列表"""
        return Response(self.git.get_project_commits(pk))

release/urls.py,代码:

from django.urls import path
from . import views

from rest_framework.routers import DefaultRouter

router = DefaultRouter()
router.register("app", views.ReleaseAPIView, "app")
router.register("gitlab", views.GitlabAPIView, "gitlab")

urlpatterns = [

] + router.urls

客户端仓库列表

创建菜单,src/views/Base.vue,代码:

<template>
  <a-layout style="min-height: 100vh">
    <a-layout-sider v-model:collapsed="collapsed" collapsible>
      <div class="logo"
           style="font-style: italic;text-align: center;font-size: 20px;color:#fff;margin: 10px 0;line-height: 50px;font-family: 'Times New Roman'">
        <span><a-switch v-model:checked="checked"/> DevOps</span>
      </div>
      <div class="logo"/>
      <a-menu v-for="menu in menu_list" v-model:selectedKeys="selectedKeys" theme="dark" mode="inline">
        <a-menu-item v-if="menu.children.length===0" :key="menu.id">

          <router-link :to="menu.menu_url">
            <desktop-outlined/>
            <span> {{ menu.title }}</span>
          </router-link>
        </a-menu-item>

        <a-sub-menu v-else :key="menu.id">
          <template #title>
            <span>
              <user-outlined/>
              <span>{{ menu.title }}</span>
            </span>
          </template>
          <a-menu-item v-for="child_menu in menu.children" :key="child_menu.id">
            <router-link :to="child_menu.menu_url">{{ child_menu.title }}</router-link>
          </a-menu-item>
        </a-sub-menu>
      </a-menu>
    </a-layout-sider>
    <a-layout>
      <a-layout-header style="background: #fff; padding: 20px">


        <a-row type="flex" justify="start">

          <a-col :span="6">
            <a-breadcrumb>
              <a-breadcrumb-item href="">
                <home-outlined/>
              </a-breadcrumb-item>
              <a-breadcrumb-item href="">
                <user-outlined/>
                <span>Application List</span>
              </a-breadcrumb-item>
              <a-breadcrumb-item>Application</a-breadcrumb-item>
            </a-breadcrumb>
          </a-col>

          <a-col :span="1" :offset="17">
            <a-breadcrumb>
              <a-button @click="logout" type="primary" class="logout">
                注销
              </a-button>
            </a-breadcrumb>
          </a-col>

        </a-row>

      </a-layout-header>

      <a-layout-content style="margin: 0 16px">

        <router-view></router-view>

      </a-layout-content>
      <a-layout-footer style="text-align: center">
        Ant Design ©2018 Created by Ant UED
      </a-layout-footer>
    </a-layout>
  </a-layout>
</template>
<script>


import {
  DesktopOutlined,
  FileOutlined,
  PieChartOutlined,
  TeamOutlined,
  UserOutlined,
  HomeOutlined
} from '@ant-design/icons-vue';
import {defineComponent, ref} from 'vue';

export default defineComponent({
  setup() {
    const checked = ref(true);
    return {
      checked,
    };
  },
  components: {
    PieChartOutlined,
    DesktopOutlined,
    UserOutlined,
    TeamOutlined,
    FileOutlined,
    HomeOutlined,
  },

  data() {
    return {
      collapsed: ref(false),
      selectedKeys: ref(['1']),
      menu_list: [
        {
          id: 1, icon: 'mail', title: '展示中心', tube: '', 'menu_url': '/uric/show_center', children: []
        },
        {
          id: 2, icon: 'mail', title: '资产管理', 'menu_url': '/uric/host', children: []
        },
        {
          "id": 3, icon: 'bold', title: '批量任务', tube: '', menu_url: '/uric/workbench', children: [
            {id: 10, icon: 'mail', title: '执行任务', 'menu_url': '/uric/multi_exec'},
            {id: 11, icon: 'mail', title: '命令管理', 'menu_url': '/uric/template_manage'},
          ]
        },
        {
          id: 4, icon: 'highlight', title: '代码发布', tube: '', menu_url: '/uric/workbench', children: [
            {id: 23, title: '仓库管理', menu_url: '/uric/git'},
            {id: 12, title: '应用管理', menu_url: '/uric/release'},
            {id: 13, title: '发布申请', menu_url: '/uric/release'}
          ]
        },
        {id: 5, icon: 'mail', title: '定时计划', tube: '', menu_url: '/uric/workbench', children: []},
        {
          id: 6, icon: 'mail', title: '配置管理', tube: '', menu_url: '/uric/workbench', children: [
            {id: 14, title: '环境管理', 'menu_url': '/uric/environment'},
            {id: 15, title: '服务配置', 'menu_url': '/uric/workbench'},
            {id: 16, title: '应用配置', 'menu_url': '/uric/workbench'}
          ]
        },
        {id: 7, icon: 'mail', title: '监控预警', tube: '', 'menu_url': '/uric/workbench', children: []},
        {
          id: 8, icon: 'mail', title: '报警', tube: '', 'menu_url': '/uric/workbench', children: [
            {id: 17, title: '报警历史', 'menu_url': '/uric/workbench'},
            {id: 18, title: '报警联系人', 'menu_url': '/uric/workbench'},
            {id: 19, title: '报警联系组', 'menu_url': '/uric/workbench'}
          ]
        },
        {
          id: 9, icon: 'mail', title: '用户管理', tube: '', menu_url: '/uric/workbench', children: [
            {id: 20, title: '账户管理', tube: '', menu_url: '/uric/workbench'},
            {id: 21, title: '角色管理', tube: '', menu_url: '/uric/workbench'},
            {id: 22, title: '系统设置', tube: '', menu_url: '/uric/workbench'}
          ]
        }
      ]
    };
  },
  methods: {
    logout() {
      let self = this;
      this.$confirm({
        title: 'Uric系统提示',
        content: '您确认要注销登陆吗?',
        onOk() {
          self.$store.commit('setToken', '')
          self.$router.push('/login')
        }
      })
    },
  }

});
</script>
<style>
#components-layout-demo-side .logo {
  height: 32px;
  margin: 16px;
}

.site-layout .site-layout-background {
  background: #fff;
}

[data-theme='dark'] .site-layout .site-layout-background {
  background: #141414;
}

.logout {
  line-height: 1.5715;
}
</style>

客户端展示仓库列表以及仓库下的分支列表和commit历史版本列表,views/Git.vue,代码:

<template>
  <div class="release">
    <div class="app_list">
      <a-table :columns="columns" :data-source="gitList" row-key="id">
        <template #bodyCell="{ column, text, record }">
          <template v-if="column.dataIndex === 'name'">
            <LockOutlined v-if="record.visibility==='private'"></LockOutlined>
            <TeamOutlined v-if="record.visibility==='internal'"></TeamOutlined>
            <GlobalOutlined v-if="record.visibility==='public'"></GlobalOutlined>
             {{record.name}}
          </template>
          <template v-if="column.dataIndex === 'owner'">
            <a :href="record.web_url">{{record.owner?.name}}</a>
          </template>
          <template v-if="column.dataIndex === 'web_url'">
            <a :href="record.web_url">{{record.web_url}}</a>
          </template>
          <template v-if="column.dataIndex === 'action'">
            <a @click="showBranchModal(record)">分支管理</a>
            <span style="color: lightgray"> | </span>
            <a @click="showCommitModal(record)">历史版本</a>
            <span style="color: lightgray"> | </span>
            <a>编辑</a>
            <span style="color: lightgray"> | </span>
            <a>删除</a>
          </template>
        </template>
      </a-table>
    </div>
  </div>

  <!-- 分支管理 -->
  <a-modal v-model:visible="branchVisible" width="1000px" title="分支管理">
    <a-table :columns="branchColumns" :data-source="branchList" row-key="id">
        <template #bodyCell="{ column, text, record }">
          <template v-if="column.dataIndex === 'web_url'">
            <a :href="record.web_url">{{record.web_url}}</a>
          </template>
          <template v-if="column.dataIndex === 'commit'">
            <span :title="record.commit.id">{{record.commit.short_id}}</span>
            <span style="color: lightgray"> | </span>
            <span>{{record.commit.committer_name}}</span>
            <span style="color: lightgray"> | </span>
            <span>{{record.commit.message}}</span>
          </template>
          <template v-if="column.dataIndex === 'action'">
            <a>编辑</a>
            <span style="color: lightgray"> | </span>
            <a>删除</a>
          </template>
        </template>
      </a-table>
  </a-modal>


  <!-- 分支管理 -->
  <a-modal v-model:visible="commitVisible" width="1000px" title="历史版本管理">
    <a-table :columns="commitColumns" :data-source="commitList" row-key="id">
        <template #bodyCell="{ column, text, record }">
          <template v-if="column.dataIndex === 'short_id'">
            <a-tooltip placement="top">
            <template #title>
              <span>{{record.id}}</span>
            </template>
            <span>{{record.short_id}}</span>
            </a-tooltip>
          </template>
          <template v-if="column.dataIndex === 'web_url'">
            <a :href="record.web_url">{{record.web_url}}</a>
          </template>
          <template v-if="column.dataIndex === 'action'">
            <a>编辑</a>
            <span style="color: lightgray"> | </span>
            <a>删除</a>
          </template>
        </template>
      </a-table>
  </a-modal>

</template>

<script>
import {ref, reactive, watch} from 'vue';
import axios from "axios";
import settings from "@/settings";
import {message} from 'ant-design-vue';
import store from "@/store";
import {GlobalOutlined, LockOutlined, TeamOutlined} from '@ant-design/icons-vue';

export default {
  name: "Git",
  components:{
    GlobalOutlined,
    LockOutlined,
    TeamOutlined,
  },
  setup(){
    // git项目的表格字段列设置
    const columns = [
      {
        title: 'ID',
        dataIndex: 'id',
        key: 'id',
        sorter: true,
        width: 100
      },
      {
        title: '项目名称',
        dataIndex: 'name',
        key: 'name',
        sorter: true,
        width: 200
      },
      {
        title: '项目地址',
        dataIndex: 'web_url',
        key: 'web_url'
      },
      {
        title: '项目所有者',
        dataIndex: 'owner',
        key: 'owner',
        sorter: true,
        width: 150
      },
      {
        title: '描述信息',
        dataIndex: 'description',
        key: 'description'
      },
      {
        title: 'forks',
        dataIndex: 'forks_count',
        key: 'forks_count'
      },
      {
        title: 'star',
        dataIndex: 'star_count',
        key: 'star_count'
      },
      {
        title: '操作',
        dataIndex: 'action',
        width: 400,
        key: 'action', scopedSlots: {customRender: 'action'}
      },
    ]

    // git仓库列表
    const gitList = ref([])

    // 获取git仓库列表
    const get_git_list = (searchForm)=>{
      axios.get(`${settings.host}/release/gitlab/`,{
        params: searchForm,
        headers: {
          Authorization: "jwt " + store.getters.token,
        }
      })
      .then(response=>{
        gitList.value = response.data;
      }).catch((error)=>{
        message.error(error.response.data)
      })
    }

    get_git_list()


    // 分支管理
    const branchColumns = [
      {
        title: '分支名称',
        dataIndex: 'name',
        key: 'name',
        sorter: true,
        width: 150
      },
      {
        title: '最新Commit版本',
        dataIndex: 'commit',
        key: 'commit'
      },
      {
        title: '分支地址',
        dataIndex: 'web_url',
        key: 'web_url'
      },
      {
        title: '操作',
        dataIndex: 'action',
        width: 200,
        key: 'action', scopedSlots: {customRender: 'action'}
      },
    ]

    // 分支列表
    const branchList = ref([])
    // 获取指定git仓库的分支列表
    const get_branch_list = ()=>{
      axios.get(`${settings.host}/release/gitlab/${current_project_id.value}/branchs/`,{
        headers: {
          Authorization: "jwt " + store.getters.token,
        }
      })
      .then(response=>{
        branchList.value = response.data;
      }).catch((error)=>{
        message.error(error.response.data)
      })
    }

    const current_project_id = ref("")
    const branchVisible = ref(false)
    const showBranchModal = (record)=>{
      current_project_id.value = record.id
      get_branch_list()
      branchVisible.value = true
    }


    // 历史版本管理
    const commitVisible = ref(false)
    const showCommitModal = (record)=>{
      current_project_id.value = record.id
      get_commit_list()
      commitVisible.value = true;
    }
    const commitColumns = [
      {
        title: '版本ID',
        dataIndex: 'short_id',
        key: 'short_id',
        sorter: true,
        width: 150
      },
      {
        title: '版本描述',
        dataIndex: 'message',
        key: 'message'
      },
      {
        title: '提交者',
        dataIndex: 'committer_name',
        key: 'committer_name'
      },
      {
        title: '操作',
        dataIndex: 'action',
        width: 200,
        key: 'action', scopedSlots: {customRender: 'action'}
      },
    ]

    // 历史版本
    const commitList = ref([])
    const get_commit_list = ()=>{
      // 获取指定仓库的历史版本
      axios.get(`${settings.host}/release/gitlab/${current_project_id.value}/commits/`,{
        headers: {
          Authorization: "jwt " + store.getters.token,
        }
      })
      .then(response=>{
        commitList.value = response.data;
      }).catch((error)=>{
        message.error(error.response.data)
      })
    }

    return {
      columns,
      gitList,

      branchList,
      branchVisible,
      showBranchModal,
      branchColumns,

      commitVisible,
      commitColumns,
      showCommitModal,
      commitList,
    }
  }
}
</script>

<style scoped>

</style>

路由代码:

import {createRouter, createWebHistory} from 'vue-router'
import ShowCenter from '../views/ShowCenter.vue'
import Login from '../views/Login.vue'
import Base from '../views/Base'
import Host from '../views/Host'
import Console from '../views/Console'
import MultiExec from '../views/MultiExec'
import Environment from '../views/Environment'
import Release from '../views/Release'
import Git from '../views/Git'
import store from "../store"

const routes = [
    {
        path: '/uric',
        alias: '/', // 给当前路径起一个别名
        name: 'Base',
        component: Base, // 快捷键:Alt+Enter快速导包
        children: [
            {
                meta: {
                    title: '展示中心',
                    authenticate: false,
                },
                path: 'show_center',
                alias: '',
                name: 'ShowCenter',
                component: ShowCenter
            },
            {
                meta: {
                    title: '资产管理',
                    authenticate: true,
                },
                path: 'host',
                name: 'Host',
                component: Host
            },
            {
                meta: {
                    title: 'Console',
                    authenticate: true,
                },
                path: 'console/:host_id',
                name: 'Console',
                component: Console
            },
            {
                path: 'multi_exec',
                name: 'MultiExec',
                component: MultiExec,
            },
            {
                path: 'environment',
                name: 'Environment',
                component: Environment,
            },
            {
                path: 'release',
                name: 'Release',
                component: Release,
            },
            {
                path: 'git',
                name: 'Git',
                component: Git,
            }
        ]
    },


    {
        meta: {
            title: '账户登陆',
            authenticate: false,
        },
        path: '/login',
        name: 'Login',
        component: Login // 快捷键:Alt+Enter快速导包
    },
];

const router = createRouter({
    history: createWebHistory(process.env.BASE_URL),
    routes
})

router.beforeEach((to, from, next) => {
    document.title = to.meta.title;
    // console.log("to", to)
    // console.log("from", from)
    // console.log("store.getters.token:", store.getters.token)
    if (to.meta.authenticate && store.getters.token === "") {
        next({name: "Login"})
    } else {
        next()
    }
});

export default router


服务端实现仓库添加

release./views.py,代码:

from rest_framework.viewsets import ViewSet
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from uric_api.utils.gitlabapi import GitlabApi
from rest_framework.decorators import action

class GitlabAPIView(ViewSet):
    """gitlab仓库的管理"""
    git = GitlabApi()
    permission_classes = [IsAuthenticated]
    def list(self, request):
        """获取所有仓库列表"""
        return Response(self.git.get_projects())

    @action(methods=["GET"], detail=True)
    def branchs(self, request, pk):
        """获取指定项目的分支列表"""
        return Response(self.git.get_project_brances(pk))

    @action(methods=["GET"], detail=True)
    def commits(self, request, pk):
        """获取指定项目的commit历史版本"""
        return Response(self.git.get_project_commits(pk))

    def create(self, request):
        """创建仓库项目"""
        data = request.data
        result = self.git.create_project(data)
        return Response(result, status=status.HTTP_201_CREATED)

客户端实现仓库添加

src/views/Git.vue,代码:

<template>
  <a-row>
    <a-col :span="20">
      <div class="add_host" style="margin: 15px;">
        <a-button @click="showProjectModal" type="primary">
          新建
        </a-button>
      </div>
    </a-col>
  </a-row>
  <div class="release">
    <div class="app_list">
      <a-table :columns="columns" :data-source="gitList" row-key="id">
        <template #bodyCell="{ column, text, record }">
          <template v-if="column.dataIndex === 'name'">
            <LockOutlined v-if="record.visibility==='private'"></LockOutlined>
            <TeamOutlined v-if="record.visibility==='internal'"></TeamOutlined>
            <GlobalOutlined v-if="record.visibility==='public'"></GlobalOutlined>
             {{record.name}}
          </template>
          <template v-if="column.dataIndex === 'owner'">
            <a :href="record.web_url">{{record.owner?.name}}</a>
          </template>
          <template v-if="column.dataIndex === 'web_url'">
            <a :href="record.web_url">{{record.web_url}}</a>
          </template>
          <template v-if="column.dataIndex === 'action'">
            <a @click="showBranchModal(record)">分支管理</a>
            <span style="color: lightgray"> | </span>
            <a @click="showCommitModal(record)">历史版本</a>
            <span style="color: lightgray"> | </span>
            <a>编辑</a>
            <span style="color: lightgray"> | </span>
            <a>删除</a>
          </template>
        </template>
      </a-table>
    </div>
  </div>
  <!-- 分支管理 -->
  <a-modal v-model:visible="branchVisible" width="1000px" title="分支管理">
    <a-table :columns="branchColumns" :data-source="branchList" row-key="id">
        <template #bodyCell="{ column, text, record }">
          <template v-if="column.dataIndex === 'web_url'">
            <a :href="record.web_url">{{record.web_url}}</a>
          </template>
          <template v-if="column.dataIndex === 'commit'">
            <span :title="record.commit.id">{{record.commit.short_id}}</span>
            <span style="color: lightgray"> | </span>
            <span>{{record.commit.committer_name}}</span>
            <span style="color: lightgray"> | </span>
            <span>{{record.commit.message}}</span>
          </template>
          <template v-if="column.dataIndex === 'action'">
            <a>编辑</a>
            <span style="color: lightgray"> | </span>
            <a>删除</a>
          </template>
        </template>
      </a-table>
  </a-modal>
  <!-- commit历史版本管理 -->
  <a-modal v-model:visible="commitVisible" width="1000px" title="历史版本管理">
    <a-table :columns="commitColumns" :data-source="commitList" row-key="id">
        <template #bodyCell="{ column, text, record }">
          <template v-if="column.dataIndex === 'short_id'">
            <a-tooltip placement="top">
            <template #title>
              <span>{{record.id}}</span>
            </template>
            <span>{{record.short_id}}</span>
            </a-tooltip>
          </template>
          <template v-if="column.dataIndex === 'web_url'">
            <a :href="record.web_url">{{record.web_url}}</a>
          </template>
          <template v-if="column.dataIndex === 'action'">
            <a>编辑</a>
            <span style="color: lightgray"> | </span>
            <a>删除</a>
          </template>
        </template>
      </a-table>
  </a-modal>
  <!-- 添加项目 -->
  <a-modal v-model:visible="projectModelVisible" title="新建仓库" @ok="handleadProjectOk" ok-text="添加" cancel-text="取消">
    <a-form ref="addProjectRuleForm" :model="project_form" :rules="add_project_rules" :label-col="labelCol" :wrapper-col="wrapperCol">
      <a-form-item ref="name" label="仓库名称" prop="name">
        <a-input v-model:value="project_form.name"/>
      </a-form-item>
      <a-form-item ref="path" label="仓库路径" prop="path">
        <a-input v-model:value="project_form.path"/>
      </a-form-item>
      <a-form-item label="仓库描述" prop="description">
        <a-textarea v-model:value="project_form.description"/>
      </a-form-item>
      <a-form-item ref="default_branch" label="默认分支" prop="default_branch">
        <a-select ref="select" v-model:value="project_form.default_branch">
          <a-select-option value="main" key="main">
            main
          </a-select-option>
          <a-select-option value="master" key="master">
            master
          </a-select-option>
        </a-select>
      </a-form-item>
      <a-form-item ref="visibility" label="可见度" prop="visibility">
        <a-select ref="select" v-model:value="project_form.visibility">
          <a-select-option :value="key" v-for="(visibility,key) in visibility_list" :key="key">
            {{ visibility }}
          </a-select-option>
        </a-select>
      </a-form-item>
    </a-form>
  </a-modal>
</template>
<script>
import {ref, reactive, watch} from 'vue';
import axios from "axios";
import settings from "@/settings";
import {message} from 'ant-design-vue';
import store from "@/store";
import {GlobalOutlined, LockOutlined, TeamOutlined} from '@ant-design/icons-vue';

export default {
  name: "Git",
  components:{
    GlobalOutlined,
    LockOutlined,
    TeamOutlined,
  },
  setup(){
    // git项目的表格字段列设置
    const columns = [
      {
        title: 'ID',
        dataIndex: 'id',
        key: 'id',
        sorter: true,
        width: 100
      },
      {
        title: '项目名称',
        dataIndex: 'name',
        key: 'name',
        sorter: true,
        width: 200
      },
      {
        title: '项目地址',
        dataIndex: 'web_url',
        key: 'web_url'
      },
      {
        title: '项目所有者',
        dataIndex: 'owner',
        key: 'owner',
        sorter: true,
        width: 150
      },
      {
        title: '描述信息',
        dataIndex: 'description',
        key: 'description'
      },
      {
        title: 'forks',
        dataIndex: 'forks_count',
        key: 'forks_count'
      },
      {
        title: 'star',
        dataIndex: 'star_count',
        key: 'star_count'
      },
      {
        title: '操作',
        dataIndex: 'action',
        width: 400,
        key: 'action', scopedSlots: {customRender: 'action'}
      },
    ]

    // git仓库列表
    const gitList = ref([])

    // 获取git仓库列表
    const get_git_list = (searchForm)=>{
      axios.get(`${settings.host}/release/gitlab/`,{
        params: searchForm,
        headers: {
          Authorization: "jwt " + store.getters.token,
        }
      })
      .then(response=>{
        gitList.value = response.data;
      }).catch((error)=>{
        message.error(error.response.data)
      })
    }

    get_git_list()


    // 分支管理
    const branchColumns = [
      {
        title: '分支名称',
        dataIndex: 'name',
        key: 'name',
        sorter: true,
        width: 150
      },
      {
        title: '最新Commit版本',
        dataIndex: 'commit',
        key: 'commit'
      },
      {
        title: '分支地址',
        dataIndex: 'web_url',
        key: 'web_url'
      },
      {
        title: '操作',
        dataIndex: 'action',
        width: 200,
        key: 'action', scopedSlots: {customRender: 'action'}
      },
    ]

    // 分支列表
    const branchList = ref([])
    // 获取指定git仓库的分支列表
    const get_branch_list = ()=>{
      axios.get(`${settings.host}/release/gitlab/${current_project_id.value}/branchs/`,{
        headers: {
          Authorization: "jwt " + store.getters.token,
        }
      })
      .then(response=>{
        branchList.value = response.data;
      }).catch((error)=>{
        message.error(error.response.data)
      })
    }

    const current_project_id = ref("")
    const branchVisible = ref(false)
    const showBranchModal = (record)=>{
      current_project_id.value = record.id
      get_branch_list()
      branchVisible.value = true
    }


    // 历史版本管理
    const commitVisible = ref(false)
    const showCommitModal = (record)=>{
      current_project_id.value = record.id
      get_commit_list()
      commitVisible.value = true;
    }
    const commitColumns = [
      {
        title: '版本ID',
        dataIndex: 'short_id',
        key: 'short_id',
        sorter: true,
        width: 150
      },
      {
        title: '版本描述',
        dataIndex: 'message',
        key: 'message'
      },
      {
        title: '提交者',
        dataIndex: 'committer_name',
        key: 'committer_name'
      },
      {
        title: '操作',
        dataIndex: 'action',
        width: 200,
        key: 'action', scopedSlots: {customRender: 'action'}
      },
    ]

    // 历史版本
    const commitList = ref([])
    const get_commit_list = ()=>{
      // 获取指定仓库的历史版本
      axios.get(`${settings.host}/release/gitlab/${current_project_id.value}/commits/`,{
        headers: {
          Authorization: "jwt " + store.getters.token,
        }
      })
      .then(response=>{
        commitList.value = response.data;
      }).catch((error)=>{
        message.error(error.response.data)
      })
    }


    // 添加git项目/仓库
    const projectModelVisible = ref(false)
    const showProjectModal = ()=>{
      projectModelVisible.value = true
    }

    // 新建仓库的表单内容
    const project_form = reactive({
      name:"",
      path:"",
      description:"",
      default_branch:"master",
      visibility:"private",
    })

    const visibility_list = reactive({
        "private": "私有仓库",
        "internal": "内部仓库",
        "public": "公开仓库"
    })

    const labelCol = reactive({span: 6})
    const wrapperCol = reactive({span: 14})

    const add_project_rules = reactive({  // 添加发布应用的表单数据验证规则
        name: [
          {required: true, message: '请输入仓库名称', trigger: 'blur'},
          {min: 1, max: 30, message: '仓库名称的长度必须在1~30个字符之间', trigger: 'blur'},
        ],
        path: [
          {required: true, message: '请输入仓库路径', trigger: 'blur'},
          {min: 1, max: 50, message: '仓库路径的长度必须在1~50个字符之间', trigger: 'blur'},
        ]
    })


    const handleadProjectOk = ()=>{
      // 添加应用的表单提交处理
      axios.post(`${settings.host}/release/gitlab/`, project_form,{
          headers: {
            Authorization: "jwt " + store.getters.token,
          }
        })
        .then((res)=>{
          gitList.value.push(res.data);
          message.success('添加成功!');
          projectModelVisible.value = false;
        }).catch((error)=>{
          message.error('添加失败!');
        })
    }


    return {
      columns,
      gitList,

      branchList,
      branchVisible,
      showBranchModal,
      branchColumns,

      commitVisible,
      commitColumns,
      showCommitModal,
      commitList,

      projectModelVisible,
      showProjectModal,
      project_form,
      visibility_list,
      labelCol,
      wrapperCol,
      add_project_rules,
      handleadProjectOk,
    }
  }
}
</script>

jenkins拉取代码

基本流程

image-20220903094759887

根据上面的流程,准备一台基本的部署代码的服务器,此处我们可以从163镜像站下载一个镜像(这里,我安装了ubuntu20.04)

到本地,记录这个服务器的IP地址。

IP:192.168.233.136
username: docker
password: 123

接着,我们在gitlab上创建一个仓库,例如:taobao。

image-20220903095633181

在开发机子上,拉取项目仓库,创建代码版本。

django-admin startproject taobao
cd taobao
git init  --initial-branch=master
git config user.name "Administrator"
git config user.email "admin@example.com"
git remote add origin http://192.168.101.8:8993/root/taobao.git
git add .
git commit -m "first commit"
git push -u origin master

Jenkins安装插件GitLab 与Publish Over SSH

image-20220903072213337

针对jenkins在创建构建任务中,因为安装了gitlab,所以我们可以对gitlab源码库进行配置,实现可以拉取代码到jenkins所在服务器。

image-20220903102247605

中间的凭据就是我们之前学习jenkins基本的时候创建的全局凭据。

image-20220903102511747

image-20220903102609345

点击保存。然后回到工程目录下,点击“立即构建”。

image-20220903102649470

查看终端输入。

image-20220903102710783

然后,我们可以登陆到jenkins服务器的远程终端,查看具体的代码。

image-20220903102753277

设置定时部署推送代码。当gitlab仓库的指定分支(此处,我们设置的是master分支)被检测有新的代码提交,则自动构建。

image-20220903103111249

jenkins部署代码

在jenkins的系统管理->系统配置中创建ssh服务器。

image-20220903103805820

image-20220903103813577

找到SSH的相关配置,添加SSH Server。

image-20220903104714925

image-20220903104544358

完成了上面配置填写,可以通过底下的Test Configuration来测试上面的配置项是否能正确连接到远程主机。

image-20220903104818757

上面Success表示配置没有问题,最后,点击保存配置即可。

完成了SSH服务端连接配置以后,我们接下来就可以构建项目的配置中,对构建

image-20220903105043283

image-20220903112237252

上面的操作中Exec Command中的构建后的命令操作,我们可以调整成项目运行。

cd /home/docker/Desktop/proj/taobao
pip install -r requirements.txt
kill -9 $(ps -aef | grep uwsgi | grep -v grep | awk '{print $2}')
/home/docker/.local/bin/uwsgi --ini ./uwsgi.ini

等待1分钟,jenkins自动构建项目并推送代码远程主机。

image-20220903120147302

经过上面的步骤,我们就得到了一个使用jenkins基于gitlab进行ssh推送代码到远程主机的配置。

可以通过之前等待python操作jenkins的接口,得到如下配置,发布django代码的发布流程:

<?xml version='1.1' encoding='UTF-8'?>
<project>
  <actions/>
  <description>测试代码发布</description>
  <keepDependencies>false</keepDependencies>
  <properties>
    <com.dabsquared.gitlabjenkins.connection.GitLabConnectionProperty plugin="gitlab-plugin@1.5.35">
      <gitLabConnection></gitLabConnection>
      <jobCredentialId></jobCredentialId>
      <useAlternativeCredential>false</useAlternativeCredential>
    </com.dabsquared.gitlabjenkins.connection.GitLabConnectionProperty>
  </properties>
  <scm class="hudson.plugins.git.GitSCM" plugin="git@4.11.4">
    <configVersion>2</configVersion>
    <userRemoteConfigs>
      <hudson.plugins.git.UserRemoteConfig>
        <url>http://192.168.101.8:8993/root/taobao.git</url>
        <credentialsId>2d198778-f631-4425-b35d-0918a2c61554</credentialsId>
      </hudson.plugins.git.UserRemoteConfig>
    </userRemoteConfigs>
    <branches>
      <hudson.plugins.git.BranchSpec>
        <name>*/master</name>
      </hudson.plugins.git.BranchSpec>
    </branches>
    <doGenerateSubmoduleConfigurations>false</doGenerateSubmoduleConfigurations>
    <submoduleCfg class="empty-list"/>
    <extensions/>
  </scm>
  <canRoam>true</canRoam>
  <disabled>false</disabled>
  <blockBuildWhenDownstreamBuilding>false</blockBuildWhenDownstreamBuilding>
  <blockBuildWhenUpstreamBuilding>false</blockBuildWhenUpstreamBuilding>
  <triggers>
    <hudson.triggers.SCMTrigger>
      <spec>*/1 * * * *</spec>
      <ignorePostCommitHooks>false</ignorePostCommitHooks>
    </hudson.triggers.SCMTrigger>
  </triggers>
  <concurrentBuild>false</concurrentBuild>
  <builders>
    <jenkins.plugins.publish__over__ssh.BapSshBuilderPlugin plugin="publish-over-ssh@1.24">
      <delegate>
        <consolePrefix>SSH: </consolePrefix>
        <delegate plugin="publish-over@0.22">
          <publishers>
            
            <jenkins.plugins.publish__over__ssh.BapSshPublisher plugin="publish-over-ssh@1.24">
              <configName>u192.168.233.136</configName>
              <verbose>true</verbose>
              <transfers>
                <jenkins.plugins.publish__over__ssh.BapSshTransfer>
                  <remoteDirectory>Desktop/proj/taobao</remoteDirectory>
                  <sourceFiles>*,taobao/*.*</sourceFiles>
                  <excludes></excludes>
                  <removePrefix></removePrefix>
                  <remoteDirectorySDF>false</remoteDirectorySDF>
                  <flatten>false</flatten>
                  <cleanRemote>false</cleanRemote>
                  <noDefaultExcludes>false</noDefaultExcludes>
                  <makeEmptyDirs>false</makeEmptyDirs>
                  <patternSeparator>[, ]+</patternSeparator>
                  <execCommand>cd /home/docker/Desktop/proj/taobao
pip install -r requirements.txt
kill -9 $(ps -aef | grep uwsgi | grep -v grep | awk &apos;{print $2}&apos;)
/home/docker/.local/bin/uwsgi --ini ./uwsgi.ini</execCommand>
                  <execTimeout>120000</execTimeout>
                  <usePty>false</usePty>
                  <useAgentForwarding>false</useAgentForwarding>
                  <useSftpForExec>false</useSftpForExec>
                </jenkins.plugins.publish__over__ssh.BapSshTransfer>
              </transfers>
              <useWorkspaceInPromotion>false</useWorkspaceInPromotion>
              <usePromotionTimestamp>false</usePromotionTimestamp>
            </jenkins.plugins.publish__over__ssh.BapSshPublisher>
            
          </publishers>
          <continueOnError>false</continueOnError>
          <failOnError>false</failOnError>
          <alwaysPublishFromMaster>false</alwaysPublishFromMaster>
          <hostConfigurationAccess class="jenkins.plugins.publish_over_ssh.BapSshPublisherPlugin" reference="../.."/>
        </delegate>
      </delegate>
    </jenkins.plugins.publish__over__ssh.BapSshBuilderPlugin>
  </builders>
  <publishers/>
  <buildWrappers/>
</project>

jenkins构建项目列表

服务端代码实现

添加jenkins配置到项目中,settings/dev.py,代码:

# jenkins配置信息
JENKINS = {
    "server_url": 'http://192.168.101.8:8888/',
    "username": 'admin',
    "password": '11217915472cb72a7edb9a4de8113a5928',
}

封装jenkins操作工具类,utils/jenkinsapi.py,代码:

import jenkins
from django.conf import settings


class Jenkinsapi(object):
    def __init__(self, server_url=None, username=None, password=None):
        self.server_url = settings.JENKINS['server_url'] if server_url is None else server_url
        self.username = settings.JENKINS['username'] if username is None else username
        self.password = settings.JENKINS['password'] if password is None else password
        self.conn = jenkins.Jenkins(url=self.server_url, username=self.username, password=self.password)

    def get_jobs(self):
        """
        获取所有的构建项目列表
        :return:
        """
        return self.conn.get_jobs()

    def get_job_info(self, job):
        """
        根据项目名获取构建项目
        :param job:
        :return:
        """
        return self.conn.get_job_info(job)

    def build_job(self,job,**kwargs):
        """
        开始构建项目
        :param job:
        :param kwargs:
        :return:
        """
        # dict1 = {"version":11} # 参数话构建
        # dict2 = {'Status': 'Rollback', 'BUILD_ID': '26'} # 回滚
        return self.conn.build_job(job, parameters=kwargs)

    def get_build_info(self,job, build_number):
        """
        通过构建编号获取构建项目的构建记录
        :param job:
        :param build_number:
        :return:
        """
        return self.conn.get_build_info(job,build_number)

    def get_job_config(self,job):
        '''
        获取xml文件
        '''
        return self.conn.get_job_config(job)

    def create_job(self,name,config_xml):
        '''
        任务名字
        xml格式的字符串
        '''
        return self.conn.create_job(name, config_xml)

    def update_job(self,name,config_xml):
        res = self.conn.reconfig_job(name,config_xml)
        return res


提供jenkins构建项目列表的视图代码,release/views.py,代码:

from uric_api.utils.jenkinsapi import Jenkinsapi


class JenkinsAPIView(ViewSet):
    """jenkins构建项目工程的管理"""
    permission_classes = [IsAuthenticated]
    def list(self, request):
        return Response(Jenkinsapi().get_jobs())

路由,releaese/urls.py,代码:

from django.urls import path
from . import views

from rest_framework.routers import DefaultRouter

router = DefaultRouter()
router.register("app", views.ReleaseAPIView, "app")
router.register("gitlab", views.GitlabAPIView, "gitlab")
router.register("jenkins", views.JenkinsAPIView, "jenkins")

urlpatterns = [

] + router.urls

客户端代码实现

src/views/Jenkins.vue,代码:

<template>
  <div class="search" style="margin-top: 15px;">
    <a-row>
      <a-col :span="6">
        <a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol" label="应用名称:">
          <a-input v-model:value="searchForm.app_name" placeholder="请输入"/>
        </a-form-item>
      </a-col>
      <a-col :span="6">
        <a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol" label="标记符:">
          <a-input v-model:value="searchForm.tag" placeholder="请输入"/>
        </a-form-item>
      </a-col>
      <a-col :span="6">
        <a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol" label="描述信息:">
          <a-input v-model:value="searchForm.description" placeholder="请输入"/>
        </a-form-item>
      </a-col>
      <a-col :span="6">
        <router-link to="/release">
          <a-button type="primary" style="margin-top: 3px;">刷新</a-button>
        </router-link>
      </a-col>
    </a-row>
  </div>

  <div class="add_app">
    <a-button style="margin-bottom: 20px;" @click="showAppModal">新建应用</a-button>
  </div>
  <a-modal v-model:visible="AppModelVisible" title="新建应用" @ok="handleaddappOk" ok-text="添加" cancel-text="取消">
    <a-form ref="addappruleForm" :model="app_form" :rules="add_app_rules" :label-col="labelCol" :wrapper-col="wrapperCol">
      <a-form-item ref="app_name" label="应用名称" prop="app_name">
        <a-input v-model:value="app_form.app_name"/>
      </a-form-item>
      <a-form-item ref="tag" label="唯一标识符" prop="tag"><a-input v-model:value="app_form.tag"/>
      </a-form-item>
      <a-form-item label="备注信息" prop="app_desc">
        <a-textarea v-model:value="app_form.app_desc"/>
      </a-form-item>
    </a-form>
  </a-modal>

  <div class="release">
    <div class="app_list">
      <a-table :columns="columns" :data-source="jobList" row-key="id">
        <template #bodyCell="{ column, text, record }">
          <template v-if="column.dataIndex === 'color'">
            <smile-outlined v-if="record.color === 'blue'" style="color: blue; font-size:48px;"/>
            <meh-outlined v-else-if="record.color === 'notbuilt'" style="color: orage; font-size:48px;"/>
            <frown-outlined v-else style="font-size:48px; color: red"/>
          </template>
          <template v-if="column.dataIndex === 'action'">
            <a>发布</a>
            <span style="color: lightgray"> | </span>
<!--            <a>克隆发布</a>-->
<!--            <span style="color: lightgray"> | </span>-->
            <a>编辑</a>
            <span style="color: lightgray"> | </span>
            <a>删除</a>
          </template>
        </template>
      </a-table>
    </div>
  </div>
</template>

<script>
import {ref, reactive, watch} from 'vue';
import axios from "axios";
import settings from "@/settings";
import {message} from 'ant-design-vue';
import store from "@/store";
import {SmileOutlined, MehOutlined, FrownOutlined}  from '@ant-design/icons-vue';
export default {
  components: {
    SmileOutlined,
    MehOutlined,
    FrownOutlined,
  },
  setup() {
    // 搜索栏的表单布局设置
    const formItemLayout = reactive({
      labelCol: {span: 8},
      wrapperCol: {span: 12},
    });

    // 表格字段列设置
    const columns = [
      {
        title: 'job名称',
        dataIndex: 'fullname',
        key: 'fullname',
        sorter: true,
        width: 230
      },
      {
        title: '发布状态',
        dataIndex: 'color',
        key: 'description'
      },
      {
        title: 'job访问地址',
        dataIndex: 'url',
        key: 'url'
      },
      {
        title: '操作',
        dataIndex: 'action',
        width: 300,
        key: 'action', scopedSlots: {customRender: 'action'}
      },
    ]

    // 构建项目列表
    const jobList = ref([])

    // 获取构建项目列表
    const get_job_list = (searchForm)=>{
      axios.get(`${settings.host}/release/jenkins/`,{
        // params: searchForm,
        headers: {
          Authorization: "jwt " + store.getters.token,
        }
      })
      .then(response=>{
        jobList.value = response.data;
        console.log(jobList.value)
      }).catch((error)=>{
        message.error(error.response.data)
      })
    }

    get_job_list()

    // 是否显示新建构建项目的弹窗
    const AppModelVisible = ref(false)

    const showAppModal = ()=>{
      AppModelVisible.value = true;
    }

    const labelCol = reactive({
      span: 6
    })

    const wrapperCol = reactive({
      span: 16
    })


    const app_form = reactive({               // 新建发布应用的表单数据
        app_name: '',
        tag: '',
        app_desc: '',
    })

    const add_app_rules = reactive({  // 添加发布应用的表单数据验证规则
        app_name: [
          {required: true, message: '请输入应用名称', trigger: 'blur'},
          {min: 1, max: 30, message: '应用名称的长度必须在1~30个字符之间', trigger: 'blur'},
        ],
        tag: [
          {required: true, message: '请输入应用唯一标识符', trigger: 'blur'},
          {min: 1, max: 50, message: '应用名称的长度必须在1~50个字符之间', trigger: 'blur'},
        ],
    })

    const handleaddappOk = ()=>{
      // 添加应用的表单提交处理
      let data = {
        name: app_form.app_name,
        tag: app_form.tag,
        description: app_form.app_desc,
      }
      axios.post(`${settings.host}/release/app/`,data,{
          headers: {
            Authorization: "jwt " + store.getters.token,
          }
        })
        .then((res)=>{
          releaseAppList.value.push(res.data);
          message.success('添加成功!');
          AppModelVisible.value = false;
        }).catch((error)=>{
          message.error('添加失败!');
        })
    }

    // 应用搜索
    const searchForm = reactive({
      app_name: "",
      description: "",
      tag: "",
    })

    // 监听搜索框的输入内容
    watch(
        searchForm,
        ()=>{
          get_release_app_list(searchForm)
        }
    )

    return {
      formItemLayout,
      columns,
      jobList,

      AppModelVisible,
      showAppModal,
      app_form,
      labelCol,
      wrapperCol,
      add_app_rules,
      handleaddappOk,
      searchForm,
    }
  }
}
</script>


<style scoped>
.release_btn span{
  color: #1890ff;
  cursor: pointer;
}
</style>

创建路由,并调整Base.vue中的菜单地址。router/index.js,代码:

import {createRouter, createWebHistory} from 'vue-router'
import ShowCenter from '../views/ShowCenter.vue'
import Login from '../views/Login.vue'
import Base from '../views/Base'
import Host from '../views/Host'
import Console from '../views/Console'
import MultiExec from '../views/MultiExec'
import Environment from '../views/Environment'
import Release from '../views/Release'
import Git from '../views/Git'
import Jenkins from '../views/Jenkins'
import store from "../store"

const routes = [
    {
        path: '/uric',
        alias: '/', // 给当前路径起一个别名
        name: 'Base',
        component: Base, // 快捷键:Alt+Enter快速导包
        children: [
            {
                meta: {
                    title: '展示中心',
                    authenticate: false,
                },
                path: 'show_center',
                alias: '',
                name: 'ShowCenter',
                component: ShowCenter
            },
            {
                meta: {
                    title: '资产管理',
                    authenticate: true,
                },
                path: 'host',
                name: 'Host',
                component: Host
            },
            {
                meta: {
                    title: 'Console',
                    authenticate: true,
                },
                path: 'console/:host_id',
                name: 'Console',
                component: Console
            },
            {
                path: 'multi_exec',
                name: 'MultiExec',
                component: MultiExec,
            },
            {
                path: 'environment',
                name: 'Environment',
                component: Environment,
            },
            {
                path: 'release',
                name: 'Release',
                component: Release,
            },
            {
                path: 'git',
                name: 'Git',
                component: Git,
            },
            {
                path: 'jenkins',
                name: 'Jenkins',
                component: Jenkins,
            }
        ]
    },


    {
        meta: {
            title: '账户登陆',
            authenticate: false,
        },
        path: '/login',
        name: 'Login',
        component: Login // 快捷键:Alt+Enter快速导包
    },
];

const router = createRouter({
    history: createWebHistory(process.env.BASE_URL),
    routes
})

router.beforeEach((to, from, next) => {
    document.title = to.meta.title;
    // console.log("to", to)
    // console.log("from", from)
    // console.log("store.getters.token:", store.getters.token)
    if (to.meta.authenticate && store.getters.token === "") {
        next({name: "Login"})
    } else {
        next()
    }
});

export default router


views/Base.vue,代码:

<script>


import {
  DesktopOutlined,
  FileOutlined,
  PieChartOutlined,
  TeamOutlined,
  UserOutlined,
  HomeOutlined
} from '@ant-design/icons-vue';
import {defineComponent, ref} from 'vue';

export default defineComponent({
  setup() {
    const checked = ref(true);
    return {
      checked,
    };
  },
  components: {
    PieChartOutlined,
    DesktopOutlined,
    UserOutlined,
    TeamOutlined,
    FileOutlined,
    HomeOutlined,
  },

  data() {
    return {
      collapsed: ref(false),
      selectedKeys: ref(['1']),
      menu_list: [
        {
          id: 1, icon: 'mail', title: '展示中心', tube: '', 'menu_url': '/uric/show_center', children: []
        },
        {
          id: 2, icon: 'mail', title: '资产管理', 'menu_url': '/uric/host', children: []
        },
        {
          "id": 3, icon: 'bold', title: '批量任务', tube: '', menu_url: '/uric/workbench', children: [
            {id: 10, icon: 'mail', title: '执行任务', 'menu_url': '/uric/multi_exec'},
            {id: 11, icon: 'mail', title: '命令管理', 'menu_url': '/uric/template_manage'},
          ]
        },
        {
          id: 4, icon: 'highlight', title: '代码发布', tube: '', menu_url: '/uric/workbench', children: [
            {id: 23, title: '仓库管理', menu_url: '/uric/git'},
            {id: 12, title: '应用管理', menu_url: '/uric/release'},
            {id: 13, title: '发布申请', menu_url: '/uric/jenkins'}
          ]
        },
        {id: 5, icon: 'mail', title: '定时计划', tube: '', menu_url: '/uric/workbench', children: []},
        {
          id: 6, icon: 'mail', title: '配置管理', tube: '', menu_url: '/uric/workbench', children: [
            {id: 14, title: '环境管理', 'menu_url': '/uric/environment'},
            {id: 15, title: '服务配置', 'menu_url': '/uric/workbench'},
            {id: 16, title: '应用配置', 'menu_url': '/uric/workbench'}
          ]
        },
        {id: 7, icon: 'mail', title: '监控预警', tube: '', 'menu_url': '/uric/workbench', children: []},
        {
          id: 8, icon: 'mail', title: '报警', tube: '', 'menu_url': '/uric/workbench', children: [
            {id: 17, title: '报警历史', 'menu_url': '/uric/workbench'},
            {id: 18, title: '报警联系人', 'menu_url': '/uric/workbench'},
            {id: 19, title: '报警联系组', 'menu_url': '/uric/workbench'}
          ]
        },
        {
          id: 9, icon: 'mail', title: '用户管理', tube: '', menu_url: '/uric/workbench', children: [
            {id: 20, title: '账户管理', tube: '', menu_url: '/uric/workbench'},
            {id: 21, title: '角色管理', tube: '', menu_url: '/uric/workbench'},
            {id: 22, title: '系统设置', tube: '', menu_url: '/uric/workbench'}
          ]
        }
      ]
    };
  },
  methods: {
    logout() {
      let self = this;
      this.$confirm({
        title: 'Uric系统提示',
        content: '您确认要注销登陆吗?',
        onOk() {
          self.$store.commit('setToken', '')
          self.$router.push('/login')
        }
      })
    },
  }

});
</script>

jenkins开始构建项目

服务端提供api接口,release/views.py,代码:

from uric_api.utils.jenkinsapi import Jenkinsapi
from rest_framework.decorators import action


class JenkinsAPIView(ViewSet):
    """jenkins构建项目工程的管理"""
    permission_classes = [IsAuthenticated]
    jenkins = Jenkinsapi()
    def list(self, request):
        """获取job构建项目列表"""
        return Response(self.jenkins.get_jobs())

    @action(methods=["PUT"], detail=False)
    def build(self, request):
        """开始构建项目"""
        job_name = request.data.get("name")
        self.jenkins.build_job(job_name)
        job_info = self.jenkins.get_job_info(job_name)
        number = job_info["builds"][0]["number"]
        result = self.jenkins.get_build_info(job_name, number)
        return Response(result)

客户端代码实现,views/Jenkins.vue,代码:

<template>
  <div class="search" style="margin-top: 15px;">
    <a-row>
      <a-col :span="6">
        <a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol" label="应用名称:">
          <a-input v-model:value="searchForm.app_name" placeholder="请输入"/>
        </a-form-item>
      </a-col>
      <a-col :span="6">
        <a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol" label="标记符:">
          <a-input v-model:value="searchForm.tag" placeholder="请输入"/>
        </a-form-item>
      </a-col>
      <a-col :span="6">
        <a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol" label="描述信息:">
          <a-input v-model:value="searchForm.description" placeholder="请输入"/>
        </a-form-item>
      </a-col>
      <a-col :span="6">
        <router-link to="/release">
          <a-button type="primary" style="margin-top: 3px;">刷新</a-button>
        </router-link>
      </a-col>
    </a-row>
  </div>

  <div class="add_app">
    <a-button style="margin-bottom: 20px;" @click="showAppModal">新建应用</a-button>
  </div>
  <a-modal v-model:visible="AppModelVisible" title="新建应用" @ok="handleaddappOk" ok-text="添加" cancel-text="取消">
    <a-form ref="addappruleForm" :model="app_form" :rules="add_app_rules" :label-col="labelCol" :wrapper-col="wrapperCol">
      <a-form-item ref="app_name" label="应用名称" prop="app_name">
        <a-input v-model:value="app_form.app_name"/>
      </a-form-item>
      <a-form-item ref="tag" label="唯一标识符" prop="tag"><a-input v-model:value="app_form.tag"/>
      </a-form-item>
      <a-form-item label="备注信息" prop="app_desc">
        <a-textarea v-model:value="app_form.app_desc"/>
      </a-form-item>
    </a-form>
  </a-modal>

  <div class="release">
    <div class="app_list">
      <a-table :columns="columns" :data-source="jobList" row-key="id">
        <template #bodyCell="{ column, text, record }">
          <template v-if="column.dataIndex === 'color'">
            <smile-outlined v-if="record.color === 'blue'" style="color: blue; font-size:48px;"/>
            <meh-outlined v-else-if="record.color === 'notbuilt'" style="color: orage; font-size:48px;"/>
            <frown-outlined v-else style="font-size:48px; color: red"/>
          </template>
          <template v-if="column.dataIndex === 'action'">
            <a @click="build_job(record.fullname)">发布</a>
            <span style="color: lightgray"> | </span>
<!--            <a>克隆发布</a>-->
<!--            <span style="color: lightgray"> | </span>-->
            <a>编辑</a>
            <span style="color: lightgray"> | </span>
            <a>删除</a>
          </template>
        </template>
      </a-table>
    </div>
  </div>
</template>

<script>
import {ref, reactive, watch} from 'vue';
import axios from "axios";
import settings from "@/settings";
import {message} from 'ant-design-vue';
import store from "@/store";
import {SmileOutlined, MehOutlined, FrownOutlined}  from '@ant-design/icons-vue';
export default {
  components: {
    SmileOutlined,
    MehOutlined,
    FrownOutlined,
  },
  setup() {
    // 搜索栏的表单布局设置
    const formItemLayout = reactive({
      labelCol: {span: 8},
      wrapperCol: {span: 12},
    });

    // 表格字段列设置
    const columns = [
      {
        title: 'job名称',
        dataIndex: 'fullname',
        key: 'fullname',
        sorter: true,
        width: 230
      },
      {
        title: '发布状态',
        dataIndex: 'color',
        key: 'description'
      },
      {
        title: 'job访问地址',
        dataIndex: 'url',
        key: 'url'
      },
      {
        title: '操作',
        dataIndex: 'action',
        width: 300,
        key: 'action', scopedSlots: {customRender: 'action'}
      },
    ]

    // 构建项目列表
    const jobList = ref([])

    // 获取构建项目列表
    const get_job_list = ()=>{
      axios.get(`${settings.host}/release/jenkins/`,{
        headers: {
          Authorization: "jwt " + store.getters.token,
        }
      })
      .then(response=>{
        jobList.value = response.data;
        console.log(jobList.value)
      }).catch((error)=>{
        message.error(error.response.data)
      })
    }

    get_job_list()

    // 开始构建项目
    const build_job = (job_name)=>{
      axios.put(`${settings.host}/release/jenkins/build/`,{name: job_name},{
        headers: {
          Authorization: "jwt " + store.getters.token,
        }
      })
      .then(response=>{
        message.success("构建成功!")
      }).catch((error)=>{
        message.error(error.response.data)
      })
    }

    // 是否显示新建构建项目的弹窗
    const AppModelVisible = ref(false)

    const showAppModal = ()=>{
      AppModelVisible.value = true;
    }

    const labelCol = reactive({
      span: 6
    })

    const wrapperCol = reactive({
      span: 16
    })


    const app_form = reactive({               // 新建发布应用的表单数据
        app_name: '',
        tag: '',
        app_desc: '',
    })

    const add_app_rules = reactive({  // 添加发布应用的表单数据验证规则
        app_name: [
          {required: true, message: '请输入应用名称', trigger: 'blur'},
          {min: 1, max: 30, message: '应用名称的长度必须在1~30个字符之间', trigger: 'blur'},
        ],
        tag: [
          {required: true, message: '请输入应用唯一标识符', trigger: 'blur'},
          {min: 1, max: 50, message: '应用名称的长度必须在1~50个字符之间', trigger: 'blur'},
        ],
    })

    const handleaddappOk = ()=>{
      // 添加应用的表单提交处理
      let data = {
        name: app_form.app_name,
        tag: app_form.tag,
        description: app_form.app_desc,
      }
      axios.post(`${settings.host}/release/app/`,data,{
          headers: {
            Authorization: "jwt " + store.getters.token,
          }
        })
        .then((res)=>{
          releaseAppList.value.push(res.data);
          message.success('添加成功!');
          AppModelVisible.value = false;
        }).catch((error)=>{
          message.error('添加失败!');
        })
    }

    // 应用搜索
    const searchForm = reactive({
      app_name: "",
      description: "",
      tag: "",
    })

    // 监听搜索框的输入内容
    watch(
        searchForm,
        ()=>{
          get_release_app_list(searchForm)
        }
    )

    return {
      formItemLayout,
      columns,
      jobList,
      build_job,

      AppModelVisible,
      showAppModal,
      app_form,
      labelCol,
      wrapperCol,
      add_app_rules,
      handleaddappOk,
      searchForm,
    }
  }
}
</script>


<style scoped>
.release_btn span{
  color: #1890ff;
  cursor: pointer;
}
</style>

服务端提供创建单个构建job项目的api接口,views/release.py,代码:

from uric_api.utils.jenkinsapi import Jenkinsapi
from rest_framework.decorators import action
from django.conf import settings


class JenkinsAPIView(ViewSet):
    """jenkins构建项目工程的管理"""
    permission_classes = [IsAuthenticated]
    jenkins = Jenkinsapi()
    def list(self, request):
        """获取job构建项目列表"""
        return Response(self.jenkins.get_jobs())

    @action(methods=["PUT"], detail=False)
    def build(self, request):
        """开始构建项目"""
        job_name = request.data.get("name")
        self.jenkins.build_job(job_name)
        job_info = self.jenkins.get_job_info(job_name)
        number = job_info["builds"][0]["number"]
        result = self.jenkins.get_build_info(job_name, number)
        return Response(result)

    def create(self, request):
        config_xml = f"""<?xml version='1.1' encoding='UTF-8'?>
        <project>
          <actions/>
          <description>{request.data.get('description')}</description>
          <keepDependencies>false</keepDependencies>
          <properties>
            <com.dabsquared.gitlabjenkins.connection.GitLabConnectionProperty plugin="gitlab-plugin@1.5.35">
              <gitLabConnection></gitLabConnection>
              <jobCredentialId></jobCredentialId>
              <useAlternativeCredential>false</useAlternativeCredential>
            </com.dabsquared.gitlabjenkins.connection.GitLabConnectionProperty>
          </properties>
          <scm class="hudson.plugins.git.GitSCM" plugin="git@4.11.4">
            <configVersion>2</configVersion>
            <userRemoteConfigs>
              <hudson.plugins.git.UserRemoteConfig>
                <url>{request.data.get('git')}</url>
                <credentialsId>2d198778-f631-4425-b35d-0918a2c61554</credentialsId>
              </hudson.plugins.git.UserRemoteConfig>
            </userRemoteConfigs>
            <branches>
              <hudson.plugins.git.BranchSpec>
                <name>{request.data.get('branch')}</name>
              </hudson.plugins.git.BranchSpec>
            </branches>
            <doGenerateSubmoduleConfigurations>false</doGenerateSubmoduleConfigurations>
            <submoduleCfg class="empty-list"/>
            <extensions/>
          </scm>
          <canRoam>true</canRoam>
          <disabled>false</disabled>
          <blockBuildWhenDownstreamBuilding>false</blockBuildWhenDownstreamBuilding>
          <blockBuildWhenUpstreamBuilding>false</blockBuildWhenUpstreamBuilding>
          <triggers>
            <hudson.triggers.SCMTrigger>
              <spec>{request.data.get('trigger')}</spec>
              <ignorePostCommitHooks>false</ignorePostCommitHooks>
            </hudson.triggers.SCMTrigger>
          </triggers>
          <concurrentBuild>false</concurrentBuild>
          <builders>
            <jenkins.plugins.publish__over__ssh.BapSshBuilderPlugin plugin="publish-over-ssh@1.24">
              <delegate>
                <consolePrefix>SSH: </consolePrefix>
                <delegate plugin="publish-over@0.22">
                  <publishers>
                    <jenkins.plugins.publish__over__ssh.BapSshPublisher plugin="publish-over-ssh@1.24">
                      <configName>{request.data.get('publishers')}</configName>
                      <verbose>false</verbose>
                      <transfers>
                        <jenkins.plugins.publish__over__ssh.BapSshTransfer>
                          <remoteDirectory>{request.data.get('remote_directory')}</remoteDirectory>
                          <sourceFiles>{request.data.get('source_files')}</sourceFiles>
                          <excludes></excludes>
                          <removePrefix>{request.data.get('remove_prefix')}</removePrefix>
                          <remoteDirectorySDF>false</remoteDirectorySDF>
                          <flatten>false</flatten>
                          <cleanRemote>false</cleanRemote>
                          <noDefaultExcludes>false</noDefaultExcludes>
                          <makeEmptyDirs>false</makeEmptyDirs>
                          <patternSeparator>[, ]+</patternSeparator>
                          <execCommand>{request.data.get('command')}</execCommand>
                          <execTimeout>120000</execTimeout>
                          <usePty>false</usePty>
                          <useAgentForwarding>false</useAgentForwarding>
                          <useSftpForExec>false</useSftpForExec>
                        </jenkins.plugins.publish__over__ssh.BapSshTransfer>
                      </transfers>
                      <useWorkspaceInPromotion>false</useWorkspaceInPromotion>
                      <usePromotionTimestamp>false</usePromotionTimestamp>
                    </jenkins.plugins.publish__over__ssh.BapSshPublisher>
                  </publishers>
                  <continueOnError>false</continueOnError>
                  <failOnError>false</failOnError>
                  <alwaysPublishFromMaster>false</alwaysPublishFromMaster>
                  <hostConfigurationAccess class="jenkins.plugins.publish_over_ssh.BapSshPublisherPlugin" reference="../.."/>
                </delegate>
              </delegate>
            </jenkins.plugins.publish__over__ssh.BapSshBuilderPlugin>
          </builders>
          <publishers/>
          <buildWrappers/>
        </project>
        """
        self.jenkins.create_job(request.data.get('job_name'), config_xml)
        return Response("ok", status=status.HTTP_201_CREATED)

客户端提交表单代码:

<template>
  <div class="add_app">
    <a-button style="margin: 20px 0;" @click="showAppModal">新建job项目</a-button>
  </div>
  <a-modal v-model:visible="AppModelVisible" title="新建job项目" @ok="handleAddJobOk" ok-text="添加" cancel-text="取消">
    <a-form ref="addJobForm" :model="job_form" :rules="add_job_rules" :label-col="labelCol" :wrapper-col="wrapperCol">
      <a-form-item ref="job_name" label="项目名称" prop="job_name">
        <a-input v-model:value="job_form.job_name"/>
      </a-form-item>
      <a-form-item label="备注信息" prop="description">
        <a-textarea v-model:value="job_form.description"/>
      </a-form-item>
      <a-form-item ref="git" label="Git仓库地址" prop="git">
        <a-input v-model:value="job_form.git"/>
      </a-form-item>
      <a-form-item ref="branch" label="Git分支" prop="branch">
        <a-input v-model:value="job_form.branch"/>
      </a-form-item>
      <a-form-item ref="trigger" label="设置定时构建" prop="trigger">
        <a-input v-model:value="job_form.trigger"/>
      </a-form-item>
      <a-form-item ref="publishers" label="部署环境" prop="publishers">
        <a-select ref="select" v-model:value="job_form.publishers">
          <a-select-option value="u192.168.233.136" key="u192.168.233.136">
            u192.168.233.136
          </a-select-option>
          <a-select-option value="u192.168.233.137" key="u192.168.233.137">
            u192.168.233.137
          </a-select-option>
        </a-select>
      </a-form-item>
      <a-form-item label="远程主机部署代码的目录" prop="remote_directory">
        <a-input v-model:value="job_form.remote_directory"/>
      </a-form-item>
      <a-form-item label="推送到远程主机的源码文件" prop="source_files">
        <a-input v-model:value="job_form.source_files"/>
      </a-form-item>
      <a-form-item label="构建之前要删除的远程主机目录" prop="remove_prefix">
        <a-input v-model:value="job_form.remove_prefix"/>
      </a-form-item>
      <a-form-item label="构建后执行的Shell命令" prop="command">
        <a-textarea v-model:value="job_form.command"/>
      </a-form-item>
    </a-form>
  </a-modal>

  <div class="release">
    <div class="app_list">
      <a-table :columns="columns" :data-source="jobList" row-key="id">
        <template #bodyCell="{ column, text, record }">
          <template v-if="column.dataIndex === 'color'">
            <smile-outlined v-if="record.color === 'blue'" style="color: blue; font-size:48px;"/>
            <meh-outlined v-else-if="record.color === 'notbuilt'" style="color: orage; font-size:48px;"/>
            <frown-outlined v-else style="font-size:48px; color: red"/>
          </template>
          <template v-if="column.dataIndex === 'action'">
            <a @click="build_job(record.fullname)">发布</a>
            <span style="color: lightgray"> | </span>
<!--            <a>克隆发布</a>-->
<!--            <span style="color: lightgray"> | </span>-->
            <a>编辑</a>
            <span style="color: lightgray"> | </span>
            <a>删除</a>
          </template>
        </template>
      </a-table>
    </div>
  </div>
</template>

<script>
import {ref, reactive, watch} from 'vue';
import axios from "axios";
import settings from "@/settings";
import {message} from 'ant-design-vue';
import store from "@/store";
import {SmileOutlined, MehOutlined, FrownOutlined}  from '@ant-design/icons-vue';
export default {
  components: {
    SmileOutlined,
    MehOutlined,
    FrownOutlined,
  },
  setup() {
    // 搜索栏的表单布局设置
    const formItemLayout = reactive({
      labelCol: {span: 8},
      wrapperCol: {span: 12},
    });

    // 表格字段列设置
    const columns = [
      {
        title: 'job名称',
        dataIndex: 'fullname',
        key: 'fullname',
        sorter: true,
        width: 230
      },
      {
        title: '发布状态',
        dataIndex: 'color',
        key: 'description'
      },
      {
        title: 'job访问地址',
        dataIndex: 'url',
        key: 'url'
      },
      {
        title: '操作',
        dataIndex: 'action',
        width: 300,
        key: 'action', scopedSlots: {customRender: 'action'}
      },
    ]

    // 构建项目列表
    const jobList = ref([])

    // 获取构建项目列表
    const get_job_list = ()=>{
      axios.get(`${settings.host}/release/jenkins/`,{
        headers: {
          Authorization: "jwt " + store.getters.token,
        }
      })
      .then(response=>{
        jobList.value = response.data;
        console.log(jobList.value)
      }).catch((error)=>{
        message.error(error.response.data)
      })
    }

    get_job_list()

    // 开始构建项目
    const build_job = (job_name)=>{
      axios.put(`${settings.host}/release/jenkins/build/`,{name: job_name},{
        headers: {
          Authorization: "jwt " + store.getters.token,
        }
      })
      .then(response=>{
        message.success("构建成功!")
      }).catch((error)=>{
        message.error(error.response.data)
      })
    }

    // 是否显示新建构建项目的弹窗
    const AppModelVisible = ref(false)

    const showAppModal = ()=>{
      AppModelVisible.value = true;
    }

    const labelCol = reactive({
      span: 6
    })

    const wrapperCol = reactive({
      span: 16
    })


    const job_form = reactive({               // 新建发布应用的表单数据
      job_name: "",
      description: "",
      git: "",
      branch: "",
      trigger: "",
      publishers: "",
      remote_directory: "",
      source_files: "",
      remove_prefix: "",
      command: "",
    })

    const add_job_rules = reactive({  // 添加发布应用的表单数据验证规则
        job_name: [
          {required: true, message: '请输入構建項目名称', trigger: 'blur'},
          {min: 1, max: 30, message: '应用名称的长度必须在1~30个字符之间', trigger: 'blur'},
        ],
        git: [
          {required: true, message: '请输入Gitca仓库的地址', trigger: 'blur'},
          {min: 1, max: 150, message: '应用名称的长度必须在1~150个字符之间', trigger: 'blur'},
        ],
    })

    const handleAddJobOk = ()=>{
      // 添加应用的表单提交处理
      axios.post(`${settings.host}/release/jenkins/`,job_form,{
          headers: {
            Authorization: "jwt " + store.getters.token,
          }
        })
        .then((res)=>{
          // JobList.value.push(res.data);
          message.success('新建成功!');
          AppModelVisible.value = false;
        }).catch((error)=>{
          message.error('新建失败!');
        })
    }

    return {
      formItemLayout,
      columns,
      jobList,
      build_job,

      AppModelVisible,
      showAppModal,
      job_form,
      labelCol,
      wrapperCol,
      add_job_rules,
      handleAddJobOk,
    }
  }
}
</script>


<style scoped>
.release_btn span{
  color: #1890ff;
  cursor: pointer;
}
</style>

七、定时计划

界面效果:

image-20210312194844276

创建应用

cd uric_api/apps/
python ../../manage.py startapp schedule

配置应用,settings/dev.py,代码:

INSTALLED_APPS = [
    ...
    'schedule',
		...
]

在应用中创建urls.py文件,schedule.urls,代码:

from django.urls import path,re_path
from . import views

urlpatterns = [
    
]

总路由,uric_api.urls,代码:

    path('schedule/', include('schedule.urls')),

创建模型类,保存任务计划,schedule/models.py,代码:

from django.db import models
from host.models import Host


# Create your models here.
class TaskSchedule(models.Model):
    period_way_choices = (
        (1, '普通任务'),  # 普通的异步任务
        (2, '定时任务'),  # 定时一次异步任务
        (3, '计划任务'),  # 定时多次异步任务
    )

    status_choices = (
        (1, '激活'),
        (2, '停止'),
        (3, '报错'),
    )

    period_beat = models.IntegerField(verbose_name='任务ID', help_text='django-celery-beat调度服务的任务ID,方便我们通过这个id值来控制celery的任务状态', null=True, blank=True)
    task_name = models.CharField(max_length=150, unique=True, verbose_name='任务名称')
    task_cmd = models.TextField(verbose_name='任务指令')
    period_way = models.IntegerField(choices=period_way_choices, default=1, verbose_name='任务周期方式')
    period_content = models.CharField(max_length=32, verbose_name='任务执行周期')
    period_status = models.IntegerField(choices=status_choices, default=1)

    class Meta:
        db_table = "schedule_taskschedule"
        verbose_name = "任务记录表"
        verbose_name_plural = verbose_name


class TaskHost(models.Model):
    tasks = models.ForeignKey('TaskSchedule',on_delete=models.CASCADE,verbose_name='执行的任务')
    hosts = models.ForeignKey(Host,on_delete=models.CASCADE,verbose_name='任务执行主机')

    class Meta:
        db_table = "schedule_taskhost"  # 切换选中内容中的字母大小写:ctrl+Shift+U
        verbose_name = "任务和主机的关系表"
        verbose_name_plural = verbose_name

数据迁移,同步模型的数据表到MySQL中,新开终端窗口:

python manage.py makemigrations
python manage.py migrate

celery定时计划

celery是python的一个第三方模块,是一个可插拔的功能完备的异步任务框架,开源免费,高性能,支持协程、多进程、多线程的模式来高效执行异步任务,同时,因为使用的开发者众多,所以官方资料或第三方资料比较完善,同时基于celery开发的一些周边插件也是比较成熟可靠的。常用于完成项目开发中的耗时任务或者定时任务。最新版本已经到了5.2版本。

1629604514147

安装依赖库

pip install celery==4.4.7
pip install django-celery-beat==2.0.0
pip install django-celery-results==2.0.0
pip install django-redis

windows系统下celery不要使用超过4.x以上版本,linux系统可以使用任意版本。

项目配置文件中配置django-celery-beat,stttings.dev,代码:

INSTALLED_APPS = [
    'django.contrib.admin',
    ...
    'django_celery_beat',
]

LANGUAGE_CODE = 'zh-hans'  # 使用中国语言
TIME_ZONE = 'Asia/Shanghai'  # 设置Django使用中国上海时间
# 如果USE_TZ设置为True时,Django会使用当前操作系统默认设置的时区,此时的TIME_ZONE不管有没有设置都不起作用
# 如果USE_TZ 设置为False,TIME_ZONE = 'Asia/Shanghai', 则使用上海的UTC时间。
USE_TZ = False  # 如果用的sqlit数据库,那么改为True,sqlit数据库不支持

我们当前celery要使用redis作为消息队列,所以要记得查看下,redis是否正常启动了。

OK,安装完成相关的模块以后,我们接下来要使用celery。

首先,需要在uric_api服务端项目根目录下创建一个保存celery代码的包目录celery_tasks。

在celery_tasks包目录下创建几个python文件,用于对celery进行初始化配置。

main.py(celery初始化) 、config.py(配置文件) 、 tasks.py(任务文件,文件名必须叫tasks.py)

celery_tasks/main.py,代码:

import os

# 为celery设置django相关的环境变量,方便将来在celery中调用django的代码
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'uric_api.settings.dev')

from celery import Celery
from . import config

# 创建celery实例对象[可以以项目名作为名称,或者以项目根目录名也可以]
app = Celery('uric_api')

# 从配置文件中加载celery的相关配置
app.config_from_object(config)

# 设置app自动加载任务
app.autodiscover_tasks([
    'celery_tasks', # celery会自动得根据列表中对应的目录下的tasks.py 进行搜索注册
])

celery_tasks/config.py

# 为celery设置django相关的环境变量,方便将来在celery中调用django的代码
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'uric_api.settings.dev')
from django.conf import settings

# 设置celery接受任务的队列
broker_url = 'redis://:12345678@127.0.0.1:6370/14'
# 设置celery保存任务执行结果的队列
result_backend = 'redis://:12345678@127.0.0.1:6370/15'

# celery 的启动工作数量设置[进程数量]
CELERY_WORKER_CONCURRENCY = 20

# 任务预取功能,就是每个工作的进程/线程在获取任务的时候,会尽量多拿 n 个,以保证获取的通讯成本可以压缩。
WORKER_PREFETCH_MULTIPLIER = 20

# 非常重要,有些情况下可以防止死锁
CELERYD_FORCE_EXECV = True

# celery 的 worker 执行多少个任务后进行重启操作
CELERY_WORKER_MAX_TASKS_PER_CHILD = 100

# 禁用所有速度限制,如果网络资源有限,不建议开足马力。
worker_disable_rate_limits = True

# celery beat配置
CELERY_ENABLE_UTC = False
settings.USE_TZ = True
timezone = settings.TIME_ZONE
# 保存定时任务记录的驱动类,使用mysql数据库来进行定时任务
CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'

tasks.py,任务文件,注意:保存任务代码的文件名,必须叫tasks.py,否则celery不识别。

from .main import app

# 经过@app.task装饰过,就会被celery识别为任务,否则就是普通的函数
@app.task
def task1():
    print("任务1函数正在执行....")

@app.task
def task2(a, b, c):
    print(f"任务2函数正在执行,参数:{[a, b, c]}....")

@app.task
def task3():
    print(f"任务3函数正在执行....")
    return True

@app.task
def task4(a, b, c):
    print(f"任务4函数正在执行....")
    return a, b, c

上述配置做完之后,我们需要执行数据库迁移指令,去生成django_celery_beat应用的表

python manage.py makemigrations
python manage.py migrate

完成了数据迁移以后,我们接下来就要启动celery和celery_beat,让celery正常工作起来。

# 命令必须在manage.py的父目录下执行
# 启动定时任务首先需要有一个work执行异步任务,然后再启动一个定时器触发任务。
celery -A celery_tasks.main worker -l info
# windows下关闭celery,快捷键:ctrl+C即可

# 启动定时器触发 beat  (注意:下面是一条完整指令)
celery -A celery_tasks.main beat -l info --scheduler  django_celery_beat.schedulers:DatabaseScheduler

接下来,我们还没有提供客户端的操作界面,所以我们要测试celery的定时任务,可以先在django的终端下进行测试。

普通周期任务

# 进入django提供的shell终端,执行如下指令
python manage.py shell

from django_celery_beat.models import PeriodicTask, IntervalSchedule
# executes every 10 seconds.
# 从定时器数据表中获取一个10秒的计时器信息,如果没有则先创建一个再读取出来。
schedule, _ = IntervalSchedule.objects.get_or_create(
  		every=10,
  		period=IntervalSchedule.SECONDS, # 单位,下面有说明
)
"""
   # 可以看到上面固定间隔的时间是采用秒 period=IntervalSchedule.SECONDS,
   # 如果你还想要固定其他的时间单位,可以设置其他字段参数,如下:
   IntervalSchedule.DAYS 固定间隔天数
   IntervalSchedule.HOURS 固定间隔小时数
   IntervalSchedule.MINUTES 固定间隔分钟数
   IntervalSchedule.SECONDS 固定间隔秒数
   IntervalSchedule.MICROSECONDS 固定间隔微秒

   # 可以从源码中进行查看
   # from django_celery_beat.models import PeriodicTask, IntervalSchedule                                                                                                  
   # IntervalSchedule.PERIOD_CHOICES                                                                                                                                       
   # 能够看到单位选项:
   (
      ('days', 'Days'),
      ('hours', 'Hours'),
      ('minutes', 'Minutes'),
      ('seconds', 'Seconds'),
      ('microseconds', 'Microseconds')
   )
"""
# 执行定时任务,带参数的
import json
from datetime import datetime, timedelta
# 带参数的任务写法:
period_obj = PeriodicTask.objects.create(
     interval=schedule,                  # we created this above.
     name='task4',                         # 唯一的任务名称,名字不能重复
     task='celery_tasks.tasks.task4',                           # 如果任何没有设置别名,则必须填写任务的导包路径,否则直接写上别名即可。
     args=json.dumps([5, 10, 15]),  # 异步任务有参数时,可以通过args或者kwargs来设置
     #kwargs=json.dumps({
     #   'be_careful': True,
     #}),
     expires=datetime.utcnow() + timedelta(seconds=30), # 任务的持续时间
)

# 不带参数的任务写法:
period_obj1 = PeriodicTask.objects.create(
     interval=schedule,                  # we created this above.
     name='task1',                       # 唯一的任务名称,名字不能重复
     task='celery_tasks.tasks.task1',    # 任务的导包路径
     expires=datetime.utcnow() + timedelta(seconds=30), # 任务的持续时间
)

# 可以查看所有计划任务
PeriodicTask.objects.all()
# 所有任务执行都是ok的,只要数据库改变了,那么beat任务会自动调用执行,因为celery一直处于轮询状态。

# 暂停执行两个周期性任务
task = PeriodicTask.objects.get(name="task1")
task.enabled = False # 把执行状态改成False,就可以暂停了。                       
task.save() 

# 把暂停的任务,重启激活。设置任务的 enabled 为 True 即可:
task = PeriodicTask.objects.get(name="task1")
task.enabled = True                                     
task.save()

# 删除任务
task4 = PeriodicTask.objects.get(name="task4")
task4.delete()

# 注意:如果celery中的任务文件代码发生改变,例如tasks.py中的任务逻辑修改了,都需要重启beat和worker  

基于 crontab 的周期性任务

import pytz  
# 创建周期
# https://docs.celeryproject.org/en/v4.4.7/userguide/periodic-tasks.html#crontab-schedules
from django_celery_beat.models import CrontabSchedule, PeriodicTask
schedule, _ = CrontabSchedule.objects.get_or_create( 
     minute='*', 
     hour='*', 
     day_of_week='*', 
     day_of_month='*', 
     month_of_year='*', 
     timezone=pytz.timezone('Asia/Shanghai') 
)

# 查看crontab数据表的中所有crontab定时器
CrontabSchedule.objects.all()  


# 执行周期任务
PeriodicTask.objects.create(
     crontab=schedule, # 上面创建的 crontab 对象 * * * * *,表示每分钟执行一次
     name='task3', # 设置任务的name值,还是一样,name必须唯一
     task='celery_tasks.tasks.task3',  # 指定需要周期性执行的任务,任务也可以通过args或kwargs添加参数
)

# 返回值
# <PeriodicTask: my_task2_crontab: * * * * * (m/h/d/dM/MY) Asia/Shanghai>

# 暂停执行两个周期性任务
task3 = PeriodicTask.objects.get(name="task3")
task3.enabled = False # 把执行状态改成False,就可以暂停了。                       
task3.save() 

后端编写计划任务的异步任务注册celery中。

celery_tasks/tasks.py

from .main import app

# 经过@app.task装饰过,就会被celery识别为任务,否则就是普通的函数
@app.task
def task1(a, b, c):
    print("任务1函数正在执行....")
    return a + b + c

"""
import json
from datetime import datetime, timedelta
period_obj = PeriodicTask.objects.create(
     interval=schedule,                  # we created this above.
     name='task23',                       # 唯一的任务名称,名字不能重复
     task='celery_tasks.tasks.task1',    # 任务的导包路径
     args=json.dumps([5, 10, 15]),  # 异步任务有参数时,可以通过args或者kwargs来设置
     #kwargs=json.dumps({
     #   'be_careful': True,
     #}),
     expires=datetime.utcnow() + timedelta(seconds=30), # 任务的持续时间
)
"""

@app.task
def task2():
    print("任务2函数正在执行....")

"""
period_obj1 = PeriodicTask.objects.create(
     interval=schedule,                  # we created this above.
     name='task30',                       # 唯一的任务名称,名字不能重复
     task='celery_tasks.tasks.task2',    # 任务的导包路径
     expires=datetime.utcnow() + timedelta(seconds=30), # 任务的持续时间
)
"""

# uric计划任务
import json
from host.models import Host
from django.conf import settings
from uric_api.utils.key import AppSetting
@app.task(name='schedule_task')
def schedule_task(cmd, hosts_ids):
    """计划任务"""
    hosts_objs = Host.objects.filter(id__in=hosts_ids)
    result_data = []
    private_key, public_key = AppSetting.get(settings.DEFAULT_KEY_NAME)
    for host_obj in hosts_objs:
        cli = host_obj.get_ssh(private_key)
        code, result = cli.exec_command(cmd)
        result_data.append({
            'host_id': host_obj.id,
            'host': host_obj.ip_addr,
            'status': code,
            'result': result
        })
        print('>>>>', code, result)

    return json.dumps(result_data)

Schedule/views.py

import json
import random
import pytz
from datetime import datetime, timedelta
from django_celery_beat.models import IntervalSchedule, CrontabSchedule, PeriodicTask
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import TaskSchedule, TaskHost
from django.conf import settings

class PeriodView(APIView):
    # 获取计划任务的周期类型数据返回给客户端
    def get(self,request):
        data = TaskSchedule.period_way_choices
        return Response(data)


class TaskView(APIView):
    def get(self,request):
        # 1. 获取任务列表数据返回给客户端
        # 2. 去redis中获取每个任务的执行结果展示给客户端
        return Response([])

    def post(self, request):
        task_data = request.data
        period_way = task_data.get('period_way')  # 计划任务的周期类型
        hosts_ids = task_data.get('hosts')  # 计划任务的执行的远程主机列表
        task_cmd = task_data.get('task_cmd')  # 计划任务要执行的任务指令
        period_content = task_data.get('period_content')  # 计划任务的周期的时间值
        task_name = task_data.get('task_name')  # 任务名称,注意不能重复
        try:
            PeriodicTask.objects.get(name=task_name)
            task_name = f"{task_name}-{str(random.randint(1000, 9999))}"
        except:
            pass

        if period_way == 1:  # 普通周期任务,默认单位为秒数,可以选择修改
            schedule, created = IntervalSchedule.objects.get_or_create(
                every=int(period_content),
                period=IntervalSchedule.SECONDS,
            )
            period_obj = PeriodicTask.objects.create(
                interval=schedule,    # we created this above.
                name=task_name,        # simply describes this periodic task.
                task='schedule_task',  # name of task.
                args=json.dumps([task_cmd, hosts_ids]),
                expires=datetime.utcnow() + timedelta(minutes=30)
            )
            period_beat = period_obj.id
        elif period_way == 2:  # 一次性任务
            period_beat = 1
            pass
        else:  # cron任务
            period_content_list = period_content.split(" ")
            schedule, created = CrontabSchedule.objects.get_or_create(
                minute=period_content_list[0],
                hour=period_content_list[1],
                day_of_week=period_content_list[2],
                day_of_month=period_content_list[3],
                month_of_year=period_content_list[4],
                timezone=pytz.timezone(settings.TIME_ZONE)
            )

            period_obj = PeriodicTask.objects.create(
                crontab=schedule,    # we created this above.
                name=task_name,        # simply describes this periodic task.
                task='celery_tasks.tasks.schedule_task',  # name of task.
                args=json.dumps([task_cmd, hosts_ids]),
            )
            period_beat = period_obj.id

        # 保存任务
        task_schedule_obj = TaskSchedule.objects.create(**{
            'period_beat': period_beat,  # celery-beat的任务id值
            'period_way': period_way,
            'task_cmd': task_cmd,
            'period_content': period_content,
            'task_name': task_name,
            'period_status': 1,  # 默认为激活状态
        })

        for host_id in hosts_ids:
            TaskHost.objects.create(**{
                'tasks_id': task_schedule_obj.id,
                'hosts_id': host_id,
            })

        return Response({'errmsg': 'ok'})

schedule/urls.py

from django.urls import path
from . import views

urlpatterns = [
    path('periods/', views.PeriodView.as_view()),
    path('tasks/', views.TaskView.as_view()),
]

客户端实现定时计划

views/Schedule.vue,代码:

<template>
  <div class="schedule">
    <div class="add_app" style="margin-top: 20px">
      <a-button style="margin-bottom: 20px;" @click="showScheduleModal">新建周期任务</a-button>
    </div>

    <a-modal v-model:visible="ScheduleModalVisible" title="新建周期任务" @ok="handOk" ok-text="添加" cancel-text="取消">
      <a-form
        ref="ruleForm"
        :model="form"
        :rules="rules"
        :label-col="labelCol"
        :wrapper-col="wrapperCol"
      >
        <a-form-item ref="task_name" label="任务名称:" prop="task_name">
          <a-input v-model:value="form.task_name"/>
        </a-form-item>
        <a-form-item label="请选择主机:" prop="hosts">
          <a-select
            mode="multiple"
            v-model:value="form.hosts"
            style="width: 100%"
            placeholder="请选择主机"
            @change="handleHostChange"
          >
            <a-select-option v-for="(host_value,host_index) in host_list" :key="host_index" :value="host_value.id">
             {{host_value.ip_addr}}--{{host_value.name}}
            </a-select-option>
          </a-select>
        </a-form-item>
        <a-form-item label="请选择周期方式:" prop="hosts">
          <a-select style="width: 120px" v-model:value="form.period_way" @change="handlePeriodChange">
            <a-select-option v-for="(period_value,period_index) in period_way_choices" :value="period_value[0]" :key="period_index">
              {{period_value[1]}}
            </a-select-option>
          </a-select>
        </a-form-item>
        <a-form-item ref="period_content" label="任务周期值:" prop="period_content">
          <a-input v-model:value="form.period_content" />
        </a-form-item>
        <a-form-item ref="task_cmd" label="任务指令:" prop="task_cmd">
          <v-ace-editor v-model:value="form.task_cmd" lang="html" theme="chrome" style="height: 200px"/>
        </a-form-item>
      </a-form>
    </a-modal>

    <div class="release">
      <div class="app_list">
        <a-table :columns="columns" :data-source="ScheduleList" row-key="id">
          <template #bodyCell="{ column, text, record }">
            <template v-if="column.dataIndex === 'action'">
              <a>禁用</a>
              <span style="color: lightgray"> | </span>
              <a>激活</a>
              <span style="color: lightgray"> | </span>
              <a>停止</a>
              <span style="color: lightgray"> | </span>
              <a>删除</a>
            </template>
          </template>
        </a-table>
      </div>
    </div>

  </div>
</template>

<script>
import {ref, reactive, watch} from 'vue';
import axios from "axios";
import settings from "@/settings";
import {message} from 'ant-design-vue';
import store from "@/store";


import {VAceEditor} from 'vue3-ace-editor';
import 'ace-builds/src-noconflict/mode-html';
import 'ace-builds/src-noconflict/theme-chrome';

export default {
  components: {
    VAceEditor,
  },
  setup() {

    // 表格字段列设置
    const columns = [
      {
        title: '任务名称',
        dataIndex: 'name',
        key: 'name',
        sorter: true,
        width: 230
      },
      {
        title: '任务类型',
        dataIndex: 'tag',
        key: 'tag',
        sorter: true,
        width: 150
      },
      {
        title: '任务周期',
        dataIndex: 'description',
        key: 'description'
      },
      {
        title: '操作',
        dataIndex: 'action',
        width: 300,
        key: 'action', scopedSlots: {customRender: 'action'}
      },
    ]


    // 周期任务列表
    const ScheduleList = ref([]);

    const get_tasks_list = ()=>{
      axios.get(`${settings.host}/schedule/tasks/`,{
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          ScheduleList.value = res.data;
      })
    }

    get_tasks_list();

    const labelCol = reactive({span: 4})
    const wrapperCol = reactive({span: 14})
    const other = ref('')
    const period_way_choices = ref([])  // 所有周期类型数据
    const host_list = ref([]) // 主机列表数据

    const form = reactive({
        task_name: '',
        hosts: [],
        period_way: 1,
        task_cmd:'',
        period_content:'',
    })

    const rules = reactive({
      task_name: [
        {required: true, message: '请输入任务名称', trigger: 'blur'},
      ],
    })

    // 获取主机列表
    const get_host_list = ()=>{
      axios.get(`${settings.host}/host/`,{
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          host_list.value = res.data;
      })
    }

    get_host_list();

    const get_period_data = ()=>{
        axios.get(`${settings.host}/schedule/periods/`).then((res)=>{
          period_way_choices.value = res.data;
          console.log(period_way_choices);
        }).catch((error)=>{

        })
    }

    get_period_data()

    // 是否显示添加周期任务的弹窗
    const ScheduleModalVisible = ref(false)
    const showScheduleModal = ()=>{
      ScheduleModalVisible.value = true
    }

    const handleHostChange = ()=>{

    }

    // 提交表单
    const handOk = ()=>{

    }

    return {
      columns,
      labelCol,
      wrapperCol,
      other,
      period_way_choices,
      host_list,
      form,
      rules,
      ScheduleList,
      ScheduleModalVisible,
      showScheduleModal,
      handleHostChange,
      handOk,
    }
  }
}
</script>

<style scoped>

</style>

views/Base.vue,代码:

        {id: 5, icon: 'mail', title: '定时计划', tube: '', menu_url: '/uric/schedule', children: []},

router/index.js

// ..
import Schedule from "../views/Schedule"

// ....
            {
                path: 'schedule',
                name: 'Schedule',
                component: Schedule,
            },
      ]
    },

  ]
})

完成添加任务的提交

<template>
  <div class="schedule">
    <div class="add_app" style="margin-top: 20px">
      <a-button style="margin-bottom: 20px;" @click="showScheduleModal">新建周期任务</a-button>
    </div>

    <a-modal v-model:visible="ScheduleModalVisible" title="新建周期任务" @ok="handOk" ok-text="添加" cancel-text="取消">
      <a-form
        ref="ruleForm"
        :model="form"
        :rules="rules"
        :label-col="labelCol"
        :wrapper-col="wrapperCol"
      >
        <a-form-item ref="task_name" label="任务名称:" prop="task_name">
          <a-input v-model:value="form.task_name"/>
        </a-form-item>
        <a-form-item label="请选择主机:" prop="hosts">
          <a-select
            mode="multiple"
            v-model:value="form.hosts"
            style="width: 100%"
            placeholder="请选择主机"
            @change="handleHostChange"
          >
            <a-select-option v-for="(host_value,host_index) in host_list" :key="host_index" :value="host_value.id">
             {{host_value.ip_addr}}--{{host_value.name}}
            </a-select-option>
          </a-select>
        </a-form-item>
        <a-form-item label="请选择周期方式:" prop="hosts">
          <a-select style="width: 120px" v-model:value="form.period_way" @change="handlePeriodChange">
            <a-select-option v-for="(period_value,period_index) in period_way_choices" :value="period_value[0]" :key="period_index">
              {{period_value[1]}}
            </a-select-option>
          </a-select>
        </a-form-item>
        <a-form-item ref="period_content" label="任务周期值:" prop="period_content">
          <a-input v-model:value="form.period_content" />
        </a-form-item>
        <a-form-item ref="task_cmd" label="任务指令:" prop="task_cmd">
          <v-ace-editor v-model:value="form.task_cmd" lang="html" theme="chrome" style="height: 200px"/>
        </a-form-item>
      </a-form>
    </a-modal>

    <div class="release">
      <div class="app_list">
        <a-table :columns="columns" :data-source="ScheduleList" row-key="id">
          <template #bodyCell="{ column, text, record }">
            <template v-if="column.dataIndex === 'action'">
              <a>禁用</a>
              <span style="color: lightgray"> | </span>
              <a>激活</a>
              <span style="color: lightgray"> | </span>
              <a>停止</a>
              <span style="color: lightgray"> | </span>
              <a>删除</a>
            </template>
          </template>
        </a-table>
      </div>
    </div>

  </div>
</template>

<script>
import {ref, reactive, watch} from 'vue';
import axios from "axios";
import settings from "@/settings";
import {message} from 'ant-design-vue';
import store from "@/store";


import {VAceEditor} from 'vue3-ace-editor';
import 'ace-builds/src-noconflict/mode-html';
import 'ace-builds/src-noconflict/theme-chrome';

export default {
  components: {
    VAceEditor,
  },
  setup() {

    // 表格字段列设置
    const columns = [
      {
        title: '任务名称',
        dataIndex: 'name',
        key: 'name',
        sorter: true,
        width: 230
      },
      {
        title: '任务类型',
        dataIndex: 'tag',
        key: 'tag',
        sorter: true,
        width: 150
      },
      {
        title: '任务周期',
        dataIndex: 'description',
        key: 'description'
      },
      {
        title: '操作',
        dataIndex: 'action',
        width: 300,
        key: 'action', scopedSlots: {customRender: 'action'}
      },
    ]


    // 周期任务列表
    const ScheduleList = ref([]);

    const get_tasks_list = ()=>{
      axios.get(`${settings.host}/schedule/tasks/`,{
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          ScheduleList.value = res.data;
      })
    }

    get_tasks_list();

    const labelCol = reactive({span: 4})
    const wrapperCol = reactive({span: 14})
    const other = ref('')
    const period_way_choices = ref([])  // 所有周期类型数据
    const host_list = ref([]) // 主机列表数据

    const form = reactive({
        task_name: '',
        hosts: [],
        period_way: 1,
        task_cmd:'',
        period_content:'',
    })

    const rules = reactive({
      task_name: [
        {required: true, message: '请输入任务名称', trigger: 'blur'},
      ],
    })

    // 获取主机列表
    const get_host_list = ()=>{
      axios.get(`${settings.host}/host/`,{
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          host_list.value = res.data;
      })
    }

    get_host_list();

    const get_period_data = ()=>{
        axios.get(`${settings.host}/schedule/periods/`).then((res)=>{
          period_way_choices.value = res.data;
          console.log(period_way_choices);
        }).catch((error)=>{

        })
    }

    get_period_data()

    // 是否显示添加周期任务的弹窗
    const ScheduleModalVisible = ref(false)
    const showScheduleModal = ()=>{
      ScheduleModalVisible.value = true
    }

    const handleHostChange = ()=>{

    }

    // 提交表单
    const handOk = ()=>{
      axios.post(`${settings.host}/schedule/tasks/`,form, {
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          ScheduleList.value.unshift(res.data);
      })
    }

    return {
      columns,
      labelCol,
      wrapperCol,
      other,
      period_way_choices,
      host_list,
      form,
      rules,
      ScheduleList,
      ScheduleModalVisible,
      showScheduleModal,
      handleHostChange,
      handOk,
    }
  }
}
</script>

<style scoped>

</style>

显示计划任务列表

schedule/views.py,代码:

import json
import random
import pytz
from datetime import datetime, timedelta
from django_celery_beat.models import IntervalSchedule, CrontabSchedule, PeriodicTask
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import TaskSchedule, TaskHost
from django.conf import settings
from celery.schedules import schedule
from django_celery_beat.tzcrontab import TzAwareCrontab
class PeriodView(APIView):
    # 获取计划任务的周期类型数据返回给客户端
    def get(self,request):
        data = TaskSchedule.period_way_choices
        return Response(data)


class TaskView(APIView):
    def get(self,request):
        # 1. 获取任务列表数据返回给客户端
        task_list = PeriodicTask.objects.all()
        results = [{
            "id": task.id,
            "name": task.name,
            "enabled": task.enabled,
            "type": "普通计划任务" if isinstance(task.schedule, schedule) else ("周期计划任务" if isinstance(task.schedule, TzAwareCrontab) else "定时一次任务"),
        } for task in task_list]

        # todo 2. 去redis中获取每个任务的执行结果展示给客户端

        return Response(results)

    def post(self, request):
        task_data = request.data
        period_way = task_data.get('period_way')  # 计划任务的周期类型
        hosts_ids = task_data.get('hosts')  # 计划任务的执行的远程主机列表
        task_cmd = task_data.get('task_cmd')  # 计划任务要执行的任务指令
        period_content = task_data.get('period_content')  # 计划任务的周期的时间值
        task_name = task_data.get('task_name')  # 任务名称,注意不能重复
        try:
            PeriodicTask.objects.get(name=task_name)
            task_name = f"{task_name}-{str(random.randint(1000, 9999))}"
        except:
            pass

        if period_way == 1:  # 普通周期任务,默认单位为秒数,可以选择修改
            schedule, created = IntervalSchedule.objects.get_or_create(
                every=int(period_content),
                period=IntervalSchedule.SECONDS,
            )
            period_obj = PeriodicTask.objects.create(
                interval=schedule,    # we created this above.
                name=task_name,        # simply describes this periodic task.
                task='schedule_task',  # name of task.
                args=json.dumps([task_cmd, hosts_ids]),
                expires=datetime.utcnow() + timedelta(minutes=30)
            )
            period_beat = period_obj.id
        elif period_way == 2:  # 一次性任务
            period_beat = 1
            pass
        else:  # cron任务
            period_content_list = period_content.split(" ")
            schedule, created = CrontabSchedule.objects.get_or_create(
                minute=period_content_list[0],
                hour=period_content_list[1],
                day_of_week=period_content_list[2],
                day_of_month=period_content_list[3],
                month_of_year=period_content_list[4],
                timezone=pytz.timezone(settings.TIME_ZONE)
            )

            period_obj = PeriodicTask.objects.create(
                crontab=schedule,    # we created this above.
                name=task_name,        # simply describes this periodic task.
                task='celery_tasks.tasks.schedule_task',  # name of task.
                args=json.dumps([task_cmd, hosts_ids]),
            )
            period_beat = period_obj.id

        # 保存任务
        task_schedule_obj = TaskSchedule.objects.create(**{
            'period_beat': period_beat,  # celery-beat的任务id值
            'period_way': period_way,
            'task_cmd': task_cmd,
            'period_content': period_content,
            'task_name': task_name,
            'period_status': 1,  # 默认为激活状态
        })

        for host_id in hosts_ids:
            TaskHost.objects.create(**{
                'tasks_id': task_schedule_obj.id,
                'hosts_id': host_id,
            })

        return Response({'errmsg': 'ok'})

客户端展示数据

<template>
  <div class="schedule">
    <div class="add_app" style="margin-top: 20px">
      <a-button style="margin-bottom: 20px;" @click="showScheduleModal">新建周期任务</a-button>
    </div>

    <a-modal v-model:visible="ScheduleModalVisible" title="新建周期任务" @ok="handOk" ok-text="添加" cancel-text="取消">
      <a-form
        ref="ruleForm"
        :model="form"
        :rules="rules"
        :label-col="labelCol"
        :wrapper-col="wrapperCol"
      >
        <a-form-item ref="task_name" label="任务名称:" prop="task_name">
          <a-input v-model:value="form.task_name"/>
        </a-form-item>
        <a-form-item label="请选择主机:" prop="hosts">
          <a-select
            mode="multiple"
            v-model:value="form.hosts"
            style="width: 100%"
            placeholder="请选择主机"
            @change="handleHostChange"
          >
            <a-select-option v-for="(host_value,host_index) in host_list" :key="host_index" :value="host_value.id">
             {{host_value.ip_addr}}--{{host_value.name}}
            </a-select-option>
          </a-select>
        </a-form-item>
        <a-form-item label="请选择周期方式:" prop="hosts">
          <a-select style="width: 120px" v-model:value="form.period_way" @change="handlePeriodChange">
            <a-select-option v-for="(period_value,period_index) in period_way_choices" :value="period_value[0]" :key="period_index">
              {{period_value[1]}}
            </a-select-option>
          </a-select>
        </a-form-item>
        <a-form-item ref="period_content" label="任务周期值:" prop="period_content">
          <a-input v-model:value="form.period_content" />
        </a-form-item>
        <a-form-item ref="task_cmd" label="任务指令:" prop="task_cmd">
          <v-ace-editor v-model:value="form.task_cmd" lang="html" theme="chrome" style="height: 200px"/>
        </a-form-item>
      </a-form>
    </a-modal>

    <div class="release">
      <div class="app_list">
        <a-table :columns="columns" :data-source="ScheduleList" row-key="id">
          <template #bodyCell="{ column, text, record }">
            <template v-if="column.dataIndex === 'action'">
              <a v-if="record.enabled">暂停</a>
              <a v-else>激活</a>
              <span style="color: lightgray"> | </span>
              <a>删除</a>
            </template>
          </template>
        </a-table>
      </div>
    </div>

  </div>
</template>

<script>
import {ref, reactive, watch} from 'vue';
import axios from "axios";
import settings from "@/settings";
import {message} from 'ant-design-vue';
import store from "@/store";


import {VAceEditor} from 'vue3-ace-editor';
import 'ace-builds/src-noconflict/mode-html';
import 'ace-builds/src-noconflict/theme-chrome';

export default {
  components: {
    VAceEditor,
  },
  setup() {

    // 表格字段列设置
    const columns = [
      {
        title: '任务ID',
        dataIndex: 'id',
        key: 'id',
        sorter: true,
        width: 230
      },
      {
        title: '任务名称',
        dataIndex: 'name',
        key: 'name',
        sorter: true,
        width: 150
      },
      {
        title: '任务类型',
        dataIndex: 'type',
        key: 'type'
      },
      {
        title: '操作',
        dataIndex: 'action',
        width: 300,
        key: 'action', scopedSlots: {customRender: 'action'}
      },
    ]


    // 周期任务列表
    const ScheduleList = ref([]);

    const get_tasks_list = ()=>{
      axios.get(`${settings.host}/schedule/tasks/`,{
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          ScheduleList.value = res.data;
      })
    }

    get_tasks_list();

    const labelCol = reactive({span: 4})
    const wrapperCol = reactive({span: 14})
    const other = ref('')
    const period_way_choices = ref([])  // 所有周期类型数据
    const host_list = ref([]) // 主机列表数据

    const form = reactive({
        task_name: '',
        hosts: [],
        period_way: 1,
        task_cmd:'',
        period_content:'',
    })

    const rules = reactive({
      task_name: [
        {required: true, message: '请输入任务名称', trigger: 'blur'},
      ],
    })

    // 获取主机列表
    const get_host_list = ()=>{
      axios.get(`${settings.host}/host/`,{
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          host_list.value = res.data;
      })
    }

    get_host_list();

    const get_period_data = ()=>{
        axios.get(`${settings.host}/schedule/periods/`).then((res)=>{
          period_way_choices.value = res.data;
          console.log(period_way_choices);
        }).catch((error)=>{

        })
    }

    get_period_data()

    // 是否显示添加周期任务的弹窗
    const ScheduleModalVisible = ref(false)
    const showScheduleModal = ()=>{
      ScheduleModalVisible.value = true
    }

    const handleHostChange = ()=>{

    }

    // 提交表单
    const handOk = ()=>{
      axios.post(`${settings.host}/schedule/tasks/`,form, {
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          ScheduleList.value.unshift(res.data);
      })
    }


    return {
      columns,
      labelCol,
      wrapperCol,
      other,
      period_way_choices,
      host_list,
      form,
      rules,
      ScheduleList,
      ScheduleModalVisible,
      showScheduleModal,
      handleHostChange,
      handOk,
    }
  }
}
</script>

<style scoped>

</style>

切换计划任务状态

schedule/views.py,代码:

import json
import random
import pytz
from datetime import datetime, timedelta
from django_celery_beat.models import IntervalSchedule, CrontabSchedule, PeriodicTask
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import TaskSchedule, TaskHost
from django.conf import settings
from celery.schedules import schedule
from django_celery_beat.tzcrontab import TzAwareCrontab
from rest_framework import status

class PeriodView(APIView):
    # 获取计划任务的周期类型数据返回给客户端
    def get(self,request):
        data = TaskSchedule.period_way_choices
        return Response(data)


class TaskView(APIView):
    def get(self,request):
        # 1. 获取任务列表数据返回给客户端
        task_list = PeriodicTask.objects.all()
        results = [{
            "id": task.id,
            "name": task.name,
            "enabled": task.enabled,
            "type": "普通计划任务" if isinstance(task.schedule, schedule) else ("周期计划任务" if isinstance(task.schedule, TzAwareCrontab) else "定时一次任务"),
        } for task in task_list]

        # todo 2. 去redis中获取每个任务的执行结果展示给客户端

        return Response(results)

    def post(self, request):
        task_data = request.data
        period_way = task_data.get('period_way')  # 计划任务的周期类型
        hosts_ids = task_data.get('hosts')  # 计划任务的执行的远程主机列表
        task_cmd = task_data.get('task_cmd')  # 计划任务要执行的任务指令
        period_content = task_data.get('period_content')  # 计划任务的周期的时间值
        task_name = task_data.get('task_name')  # 任务名称,注意不能重复
        try:
            PeriodicTask.objects.get(name=task_name)
            task_name = f"{task_name}-{str(random.randint(1000, 9999))}"
        except:
            pass

        if period_way == 1:  # 普通周期任务,默认单位为秒数,可以选择修改
            schedule, created = IntervalSchedule.objects.get_or_create(
                every=int(period_content),
                period=IntervalSchedule.SECONDS,
            )
            period_obj = PeriodicTask.objects.create(
                interval=schedule,    # we created this above.
                name=task_name,        # simply describes this periodic task.
                task='schedule_task',  # name of task.
                args=json.dumps([task_cmd, hosts_ids]),
                expires=datetime.utcnow() + timedelta(minutes=30)
            )
            period_beat = period_obj.id
        elif period_way == 2:  # 一次性任务
            period_beat = 1
            pass
        else:  # cron任务
            period_content_list = period_content.split(" ")
            schedule, created = CrontabSchedule.objects.get_or_create(
                minute=period_content_list[0],
                hour=period_content_list[1],
                day_of_week=period_content_list[2],
                day_of_month=period_content_list[3],
                month_of_year=period_content_list[4],
                timezone=pytz.timezone(settings.TIME_ZONE)
            )

            period_obj = PeriodicTask.objects.create(
                crontab=schedule,    # we created this above.
                name=task_name,        # simply describes this periodic task.
                task='celery_tasks.tasks.schedule_task',  # name of task.
                args=json.dumps([task_cmd, hosts_ids]),
            )
            period_beat = period_obj.id

        # 保存任务
        task_schedule_obj = TaskSchedule.objects.create(**{
            'period_beat': period_beat,  # celery-beat的任务id值
            'period_way': period_way,
            'task_cmd': task_cmd,
            'period_content': period_content,
            'task_name': task_name,
            'period_status': 1,  # 默认为激活状态
        })

        for host_id in hosts_ids:
            TaskHost.objects.create(**{
                'tasks_id': task_schedule_obj.id,
                'hosts_id': host_id,
            })

        return Response({'errmsg': 'ok'})

class TaskDetaiView(APIView):
    def put(self, request, pk):
        """激活/禁用计划任务"""
        try:
            task = PeriodicTask.objects.get(id=pk)
        except:
            return Response({"errmsg":" 当前任务不存在 !"}, status=status.HTTP_400_BAD_REQUEST)

        task.enabled = not task.enabled
        task.save()

        return Response({"errmsg": "ok"})

schedule/urls.py,代码:

from django.urls import path, re_path
from . import views

urlpatterns = [
    path('periods/', views.PeriodView.as_view()),
    path('tasks/', views.TaskView.as_view()),
    re_path('tasks/(?P<pk>\d+)/', views.TaskDetaiView.as_view()),
]

客户端实现点击切换计划任务状态

<template>
  <div class="schedule">
    <div class="add_app" style="margin-top: 20px">
      <a-button style="margin-bottom: 20px;" @click="showScheduleModal">新建周期任务</a-button>
    </div>

    <a-modal v-model:visible="ScheduleModalVisible" title="新建周期任务" @ok="handOk" ok-text="添加" cancel-text="取消">
      <a-form
        ref="ruleForm"
        :model="form"
        :rules="rules"
        :label-col="labelCol"
        :wrapper-col="wrapperCol"
      >
        <a-form-item ref="task_name" label="任务名称:" prop="task_name">
          <a-input v-model:value="form.task_name"/>
        </a-form-item>
        <a-form-item label="请选择主机:" prop="hosts">
          <a-select
            mode="multiple"
            v-model:value="form.hosts"
            style="width: 100%"
            placeholder="请选择主机"
            @change="handleHostChange"
          >
            <a-select-option v-for="(host_value,host_index) in host_list" :key="host_index" :value="host_value.id">
             {{host_value.ip_addr}}--{{host_value.name}}
            </a-select-option>
          </a-select>
        </a-form-item>
        <a-form-item label="请选择周期方式:" prop="hosts">
          <a-select style="width: 120px" v-model:value="form.period_way" @change="handlePeriodChange">
            <a-select-option v-for="(period_value,period_index) in period_way_choices" :value="period_value[0]" :key="period_index">
              {{period_value[1]}}
            </a-select-option>
          </a-select>
        </a-form-item>
        <a-form-item ref="period_content" label="任务周期值:" prop="period_content">
          <a-input v-model:value="form.period_content" />
        </a-form-item>
        <a-form-item ref="task_cmd" label="任务指令:" prop="task_cmd">
          <v-ace-editor v-model:value="form.task_cmd" lang="html" theme="chrome" style="height: 200px"/>
        </a-form-item>
      </a-form>
    </a-modal>

    <div class="release">
      <div class="app_list">
        <a-table :columns="columns" :data-source="ScheduleList" row-key="id">
          <template #bodyCell="{ column, text, record }">
            <template v-if="column.dataIndex === 'action'">
              <a v-if="record.enabled" @click="change_schedule_status(record)">暂停</a>
              <a v-else  @click="change_schedule_status(record)">激活</a>
              <span style="color: lightgray"> | </span>
              <a>删除</a>
            </template>
          </template>
        </a-table>
      </div>
    </div>

  </div>
</template>

<script>
import {ref, reactive, watch} from 'vue';
import axios from "axios";
import settings from "@/settings";
import {message} from 'ant-design-vue';
import store from "@/store";


import {VAceEditor} from 'vue3-ace-editor';
import 'ace-builds/src-noconflict/mode-html';
import 'ace-builds/src-noconflict/theme-chrome';

export default {
  components: {
    VAceEditor,
  },
  setup() {

    // 表格字段列设置
    const columns = [
      {
        title: '任务ID',
        dataIndex: 'id',
        key: 'id',
        sorter: true,
        width: 230
      },
      {
        title: '任务名称',
        dataIndex: 'name',
        key: 'name',
        sorter: true,
        width: 150
      },
      {
        title: '任务类型',
        dataIndex: 'type',
        key: 'type'
      },
      {
        title: '操作',
        dataIndex: 'action',
        width: 300,
        key: 'action', scopedSlots: {customRender: 'action'}
      },
    ]


    // 周期任务列表
    const ScheduleList = ref([]);

    const get_tasks_list = ()=>{
      axios.get(`${settings.host}/schedule/tasks/`,{
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          ScheduleList.value = res.data;
      })
    }

    get_tasks_list();

    const labelCol = reactive({span: 4})
    const wrapperCol = reactive({span: 14})
    const other = ref('')
    const period_way_choices = ref([])  // 所有周期类型数据
    const host_list = ref([]) // 主机列表数据

    const form = reactive({
        task_name: '',
        hosts: [],
        period_way: 1,
        task_cmd:'',
        period_content:'',
    })

    const rules = reactive({
      task_name: [
        {required: true, message: '请输入任务名称', trigger: 'blur'},
      ],
    })

    // 获取主机列表
    const get_host_list = ()=>{
      axios.get(`${settings.host}/host/`,{
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          host_list.value = res.data;
      })
    }

    get_host_list();

    const get_period_data = ()=>{
        axios.get(`${settings.host}/schedule/periods/`).then((res)=>{
          period_way_choices.value = res.data;
          console.log(period_way_choices);
        }).catch((error)=>{

        })
    }

    get_period_data()

    // 是否显示添加周期任务的弹窗
    const ScheduleModalVisible = ref(false)
    const showScheduleModal = ()=>{
      ScheduleModalVisible.value = true
    }

    const handleHostChange = ()=>{

    }

    // 提交表单
    const handOk = ()=>{
      axios.post(`${settings.host}/schedule/tasks/`,form, {
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          ScheduleList.value.unshift(res.data);
      })
    }

    // 切换计划任务的状态
    const change_schedule_status = (record)=>{
      axios.put(`${settings.host}/schedule/tasks/${record.id}/`,{}, {
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          record.enabled = !record.enabled;
      })
    }

    return {
      columns,
      labelCol,
      wrapperCol,
      other,
      period_way_choices,
      host_list,
      form,
      rules,
      ScheduleList,
      ScheduleModalVisible,
      showScheduleModal,
      handleHostChange,
      handOk,
      change_schedule_status,
    }
  }
}
</script>

<style scoped>

</style>

八、监控预警

监控

在python运维开发中,针对系统参数进行监控,一般实现方式有2种:

  1. 远程SSH连接监控的目标主机,远程执行对应的监控命令,获取相关的系统参数。
  2. 远程部署脚本,通过在监控的目标主机上,上传安装对应的程序客户端,执行参数收集的程序。

psutil是一个开源的跨平台的系统信息操作模块,支持Linux、windows等大部分操作系统,是系统管理员和运维人员不可或缺的必备模块。其提供了便利的函数用来获取才做系统的信息,比如CPU,内存,磁盘,网络等。此外,psutil还可以用来进行进程管理,包括判断进程是否存在、获取进程列表、获取进程详细信息等。而且psutil还提供了许多命令行工具提供的功能,包括:ps,top,lsof,netstat,ifconfig, who,df,kill,free,nice,ionice,iostat,iotop,uptime,pidof,tty,taskset,pmap。

根据psutils提供的功能函数的功能,主要分为CPU、磁盘、内存、网络等几类。

官方文档:https://psutil.readthedocs.io/en/latest/

安装命令:

pip install psutil -i https://pypi.douban.com/simple
# python -m pip install psutil -i https://pypi.douban.com/simple
函数 描述
cpu_count(,[logical]) 获取CPU的个数,默认获取逻辑CPU个数,当logical=False时,获取物理CPU的个数。
cpu_times(,[percpu]) 获取系统CPU时间信息,percpu=True表示获取每个CPU的时间信息
cpu_times_percent(,[percpu]) 功能和cpu_times大致相同,返回耗时比例。
cpu_percent(,[percpu],[interval]) 读取CPU的利用率,percpu=True时显示所有物理核心的利用率,interval!=0时,则阻塞时显示interval执行的时间内的平均利用率
cpu_stats() 以元组的形式返回CPU的统计信息,包括上下文切换,中断,软中断和系统调用次数。
cpu_freq([percpu]) 返回cpu频率,percpu=True时,返回单个cpu频率
getloadavg() 以元组的形式返回最近1、5和15分钟内的平均系统负载。
virtual_memory() 获取系统内存的使用情况
swap_memory() 获取系统交换内存的统计信息
disk_partitions() 获取磁盘的分区数据
disk_usage(path) 获取指定路径所属的分区磁盘的使用统计信息
disk_io_counters() 获取磁盘io操作相关信息
net_io_counters(pernic=False, nowrap=True) 获取网络 I/O 统计信息
net_connections(kind='inet') 网卡的连接信息
net_if_addrs() 网卡的地址信息
net_if_stats() 网卡的状态信息
boot_time() 系统启动时间
users() 当前连接到操作系统的用户列表

基本使用

import psutil

"""查看cpu个数"""
# print( psutil.cpu_count() )  # 4,逻辑CPU个数
# print( psutil.cpu_count(logical=False) ) # 2,物理CPU个数

"""查看cpu运行状态信息"""
# print( psutil.cpu_times(percpu=True) )
# scputimes(
#   user=937.05,    # 用户进程使用的CPU时间累计
#   nice=26.94,     # 优先级为负值的进程使用时间
#   system=211.48,  # 系统内核进程使用时间累计
#   idle=1480.49,   # CPU空闲时间累计
#   iowait=15.02,   # 等待IO花费的时间
#   irq=0.0,        # 硬中断时间累计
#   softirq=26.94,  # 软中断时间累计
#   steal=0.0,      # 花费在虚拟机中的时间
#   guest=0.0,
#   guest_nice=0.0
# )

"""查看cpu运行状态比例信息"""
# print(psutil.cpu_times_percent())
# scputimes(user=0.0, nice=0.0, system=0.0, idle=1.0, iowait=0.0, irq=0.0, softirq=0.0, steal=0.0, guest=0.0, guest_nice=0.0)


"""查看cpu利用率"""
# print( psutil.cpu_percent(interval=None) )  # 0.0
# print( psutil.cpu_percent(interval=1) )  # 4.3
# print( psutil.cpu_percent(percpu=True) )  # [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]


"""CPU的统计信息"""
# print(psutil.cpu_stats())
# scpustats(
#   ctx_switches=6672143,  # 自操作系统启动的上下文切换次数
#   interrupts=4119465,    # 自操作系统启动以来的中断数
#   soft_interrupts=2539436,  # 自操作系统启动以来的软中断数
#   syscalls=0                # 自操作系统启动以来的系统调用次数
# )

"""CPU的频率"""
# print(psutil.cpu_freq())
# scpufreq(
#   current=2208.0,   # 当前频率
#   min=0.0,          # 最小频率
#   max=0.0           # 最大频率
# )

"""以元组的形式返回最近1、5和15分钟内的平均系统负载。"""
# print(psutil.getloadavg())  # (0.48, 0.39, 0.22)


"""获取内存信息"""
# print(psutil.virtual_memory())
# svmem(
#   total=5420404736,     # 总物理内存,total = used + free
#   available=1328689152, # 可用内存,free = buffers + cached
#   percent=75.5,         # 已用内存的百分比,percent=(total - available) / total * 100
#   used=3783630848,      # 物理已使用的内存
#   free=112947200,       # 物理没使用的空闲内存
#   active=818221056,     # 当前正在使用或最近使用的内存
#   inactive=3860795392,  # 未使用的内存
#   buffers=98254848,     # 缓冲区内存
#   cached=1425571840,    # 缓存占用内存
#   shared=8818688,       # 可以被多个进程同时访问的内存
#   slab=289615872        # 缓存内核数据结构的内存
# )

"""以人类可读的方式输出内存大小"""
# from psutil._common import bytes2human
# ret = bytes2human(psutil.virtual_memory()[0])
# print(ret)  # 5.0G


"""系统交换内存统计信息"""
# print(psutil.swap_memory())
# sswap(
#   total=2147479552,   # 总交换内存
#   used=284708864,     # 已使用的交换内存
#   free=1862770688,    # 未使用过的空闲交换内存
#   percent=13.3,       # 已用交换内存的百分比,percent=(total - available) / total * 100
#   sin=100696064,      # 系统累积从硬盘转入的字节数
#   sout=342335488      # 系统累积从硬盘转出的字节数
# )

"""磁盘分区数据"""
# print(psutil.disk_partitions())
# [
#   sdiskpart(device='设备路径', mountpoint='挂载点路径', fstype='分区文件系统', opts='以逗号分隔的字符串,指示驱动器/分区的不同挂载选项'),
#   sdiskpart(device='/dev/sda5', mountpoint='/', fstype='ext4', opts='rw,relatime,errors=remount-ro'),
#   sdiskpart(device='/dev/loop0', mountpoint='/snap/core18/2284', fstype='squashfs', opts='ro,nodev,relatime'),
#   sdiskpart(device='/dev/sda1', mountpoint='/boot/efi', fstype='vfat', opts='rw,relatime,fmask=0077,dmask=0077,codepage=437,iocharset=iso8859-1,shortname=mixed,errors=remount-ro')
# ]


"""获取指定路径所属的分区磁盘的使用统计信息"""
# print(psutil.disk_usage("/"))
# sdiskusage(
#   total=52044496896,  # 总磁盘空间
#   used=40965885952,   # 已用磁盘空间
#   free=8404480000,    # 用户可用的空间
#   percent=83.0        # 用户对磁盘的利用率
# )


"""磁盘io操作相关信息"""
# print(psutil.disk_io_counters())
# sdiskio(
#   read_count=130506,  # 读取次数
#   write_count=74714,  # 写入次数
#   read_bytes=4225399296,  # 读取的字节数
#   write_bytes=3102049280, # 写入的字节数
#   read_time=52547,        # 从磁盘读取所花费的时间(以毫秒为单位)
#   write_time=24460,       # 写入磁盘所花费的时间(以毫秒为单位)
#   read_merged_count=48032,   # 合并读取的数量
#   write_merged_count=152722, # 合并写入的数量
#   busy_time=178944        # 实际 I/O 所花费的时间 (以毫秒为单位)
# )

"""获取网络 I/O 统计信息"""
# print(psutil.net_io_counters())
# snetio(
#   bytes_sent=1416706,   # 网卡接收的数据字节数
#   bytes_recv=14261510,  # 网卡接收的数据字节数
#   packets_sent=20303,   # 网卡发送的数据包数
#   packets_recv=27037,   # 网卡接收到的数据包数
#   errin=0,              # 网卡接收数据时的错误总数
#   errout=0,             # 网卡发送数据时的错误总数
#   dropin=0,             # 网卡在网络请求被丢弃的传入数据包总数
#   dropout=0             # 网卡在网络请求被丢弃的传出数据包总数
# )

# print(psutil.net_io_counters().bytes_sent)
# print(psutil.net_io_counters().bytes_recv)
# print(psutil.net_io_counters().packets_sent)
# print(psutil.net_io_counters().packets_recv)


"""网卡的连接信息"""
# print(psutil.net_connections())
# [
#   sconn(fd=套接字文件描述符(windows和Linux下为-1), family=地址族, type=地址类型, laddr=本地地址, raddr=绝对地址, status=TCP连接的状态, pid=打开套接字的进程的 PID进程ID),
#   sconn(fd=-1, family=<AddressFamily.AF_INET6: 10>, type=<SocketKind.SOCK_STREAM: 1>, laddr=addr(ip='::', port=22), raddr=(), status='LISTEN', pid=None),
#   sconn(fd=-1, family=<AddressFamily.AF_INET6: 10>, type=<SocketKind.SOCK_STREAM: 1>, laddr=addr(ip='::', port=22), raddr=(), status='LISTEN', pid=None),
#   sconn(fd=-1, family=<AddressFamily.AF_INET: 2>, type=<SocketKind.SOCK_STREAM: 1>, laddr=addr(ip='0.0.0.0', port=22), raddr=(), status='LISTEN', pid=None),
#   sconn(fd=-1, family=<AddressFamily.AF_INET: 2>, type=<SocketKind.SOCK_STREAM: 1>, laddr=addr(ip='127.0.0.1', port=6379), raddr=(), status='LISTEN', pid=None),
#   sconn(fd=-1, family=<AddressFamily.AF_INET: 2>, type=<SocketKind.SOCK_DGRAM: 2>, laddr=addr(ip='192.168.233.129', port=68), raddr=addr(ip='192.168.233.254', port=67), status='NONE', pid=None),
#   sconn(fd=-1, family=<AddressFamily.AF_INET: 2>, type=<SocketKind.SOCK_STREAM: 1>, laddr=addr(ip='0.0.0.0', port=80), raddr=(), status='LISTEN', pid=None),
#   sconn(fd=67, family=<AddressFamily.AF_INET6: 10>, type=<SocketKind.SOCK_STREAM: 1>, laddr=addr(ip='::ffff:127.0.0.1', port=63342), raddr=(), status='LISTEN', pid=3993),
# ]


"""网卡的地址信息"""
# print(psutil.net_if_addrs())
# {
#   'lo': [
#     snicaddr(family=地址族, address='主网卡地址', netmask='网络掩码地址', broadcast=广播地址, ptp=点对点接口(通常是 VPN)上的目标地址),
#     snicaddr(family=<AddressFamily.AF_INET: 2>, address='127.0.0.1', netmask='255.0.0.0', broadcast=None, ptp=None),
#     snicaddr(family=<AddressFamily.AF_INET6: 10>, address='::1', netmask='ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', broadcast=None, ptp=None),
#     snicaddr(family=<AddressFamily.AF_PACKET: 17>, address='00:00:00:00:00:00', netmask=None, broadcast=None, ptp=None)
#   ],
#   'ens33': [
#     snicaddr(family=<AddressFamily.AF_INET: 2>, address='192.168.233.129', netmask='255.255.255.0', broadcast='192.168.233.255', ptp=None),
#     snicaddr(family=<AddressFamily.AF_INET6: 10>, address='fe80::59eb:3d0b:84e:950f%ens33', netmask='ffff:ffff:ffff:ffff::', broadcast=None, ptp=None),
#     snicaddr(family=<AddressFamily.AF_PACKET: 17>, address='00:0c:29:7e:42:9d', netmask=None, broadcast='ff:ff:ff:ff:ff:ff', ptp=None)
#   ]
# }


"""网卡的状态信息"""
# print(psutil.net_if_stats())
# # {
# #   'lo': snicstats(isup=表示以太网电缆或 Wi-Fi 已连接, duplex=双工通信类型, speed=网卡速度, mtu=网卡的最大传输单位,以字节表示),
# #   'lo': snicstats(isup=True, duplex=<NicDuplex.NIC_DUPLEX_UNKNOWN: 0>, speed=0, mtu=65536),
# #   'ens33': snicstats(isup=True, duplex=<NicDuplex.NIC_DUPLEX_FULL: 2>, speed=1000, mtu=1500)
# # }

"""系统启动时间"""
# print(psutil.boot_time())  # 1650281599.0

"""当前连接到操作系统的用户列表"""
# print(psutil.users())
# [
#   用户类型(name='用户账号名', terminal='终端类型', host='客户端地址', started=创建连接的时间戳, pid=进程ID)
#   suser(name='moluo', terminal=':0', host='localhost', started=1650267136.0, pid=2837)
# ]

进程信息管理

import psutil


"""获取当前系统运行的所有进程ID(PID)的排序列表"""
# print(psutil.pids())
# # [1, 2, ... 5509, 5516, 5539]


"""判断指定的pid是否存在"""
# pid_num = psutil.pids()[-10]  # psutil.pids()[-10] 仅仅是举例
# result = psutil.pid_exists(pid_num)

"""获取指定pid的进程信息"""
# pid_num = psutil.pids()[-1]
# pid_info=psutil.Process(pid_num)
# print(f"进程名:{pid_info.name()}")
# print(f"进程信息:{pid_info}")
# print(f"启动执行命令:{pid_info.exe()}")
# print(f"进程工作目录:{pid_info.cwd()}")
# print(f"进程执行状态:{pid_info.status()}")
# print(f"进程创建时间:{pid_info.create_time()}")
# print(f"进程uid信息:{pid_info.uids()}")
# print(f"进程gid信息:{pid_info.gids()}")
# print(f"进程的cpu时间信息:{pid_info.cpu_times()}")
# print(f"进程内存利用率:{pid_info.memory_percent()}")
# print(f"进程的IO信息,包括读写IO数字及参数:{pid_info.io_counters()}")
# print(f"进程开启的线程数:{pid_info.num_threads()}")


"""获取当前系统运行的所有进程相关信息"""
# for proc in psutil.process_iter(['pid', 'create_time', 'name', 'status', 'memory_percent', 'username', 'cmdline']):
#     print(proc.info)

本地执行终端命令

import psutil

"""psutil.Popen对象,终端执行命令"""
# python3.8 -c 'print("hello world")'
my_process=psutil.Popen(["python3.8","-c",'print("hello world")'])
# 用户程序的信息获取
print("用户进程的名称:{}".format(my_process.name()))
print("用户进程的启动用户:{}".format(my_process.username()))
print("用户进程的输出信息:{}".format(my_process.communicate()))

实现top命令效果

# !/usr/bin/env python
import time
import psutil
from datetime import datetime
from psutil._common import bytes2human as size

# 获取系统启动时间
print(f"{'-'*32} 系统时间 {'-'*32}")
uptime = datetime.fromtimestamp(psutil.boot_time()).strftime("%Y-%m-%d %H:%M:%S")
# 系统当前本地时间
now_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))
print(f"系统启动时间: {uptime}\t系统本地时间:{now_time}")

# 获取系统用户
users_count = len(psutil.users())
users_list = ",".join([u.name for u in psutil.users()])
print(f"当前有{users_count}个用户:{users_list}")

print(f"{'-'*32} CPU信息 {'-'*32}")
# 物理cpu个数
cpu_count = psutil.cpu_count(logical=False)
# cpu内核个数
logical_cpu_count = psutil.cpu_count()
# cpu的使用率
cpu_percent = psutil.cpu_percent(1)
# cpu的平均负载
cpu_loadavg = " ".join([str(item) for item in psutil.getloadavg()])
print(f"CPU个数: {cpu_count}\t内核个数:{logical_cpu_count}\tcup使用率: {cpu_percent}%\tCPU负载参数:{cpu_loadavg}")

print(f"{'-'*32} 内存信息 {'-'*32}")
# 查看物理内存信息
memory = psutil.virtual_memory()
free = size(memory.free)
total = size(memory.total)
memory_percent = (memory.total - memory.free) / memory.total
print(f"总物理内存:{total}\t剩余物理内存:{free:10s}物理内存使用率:{int(memory_percent * 100)}%")
# 查看交换内存信息
swap = psutil.swap_memory()
free = size(swap.free)
total = size(swap.total)
swap_percent = (swap.total - swap.free) / swap.total
print(f"总交换内存:{total}\t剩余交换内存:{free:10s}交换内存使用率:{int(swap_percent * 100)}%")


print(f"{'-'*32} 网卡信息 {'-'*32}")
# 获取网卡信息,可以得到得到网卡属性,连接数,当前数据等信息
net = psutil.net_io_counters()
bytes_sent = size(net.bytes_recv)
bytes_rcvd = size(net.bytes_sent)
print(f"网卡接收数据:{bytes_rcvd}\t网卡发送数据:{bytes_sent}")

# 获取磁盘数据信息
print(f"{'-'*32} 磁盘信息 {'-'*32}")
io = psutil.disk_partitions()
for i in io:
    try:
        o = psutil.disk_usage(i.device)
        print(f"设备:{i.device:12s}总容量:{size(o.total):6s}已用容量:{size(o.used):6s}可用容量:{size(o.free)}")
    except PermissionError:
        continue

print(f"{'-'*32} 进程信息 {'-'*32}")
# 查看系统全部进程
for pnum in psutil.pids():
    p = psutil.Process(pnum)
    print(f"进程名:{p.name():20.20s}内存利用率:{p.memory_percent():.2f}\t进程状态:{p.status():10s}创建时间:{datetime.fromtimestamp(p.create_time()):%Y-%m-%d %H:%M:%S}")

监控参数整理

monitor.py,代码:

# !/usr/bin/env python
import time, json
from datetime import datetime
try:
    import psutil
except:
    os.system("pip install psutil")
    import psutil
from psutil._common import bytes2human as size

data = {}
# 获取系统启动时间
data["uptime"] = datetime.fromtimestamp(psutil.boot_time()).strftime("%Y-%m-%d %H:%M:%S")
# 系统当前本地时间
data['time'] = datetime.fromtimestamp(time.time()).strftime("%Y-%m-%d %H:%M:%S")

# 获取当前连接到系统的用户
data["users_count"] = len(psutil.users())
data["users"] = [{"name": user.name, "pid": user.pid, "terminal": user.terminal, "host": user.host, "started": datetime.fromtimestamp(user.started).strftime("%Y-%m-%d %H:%M:%S")} for user in psutil.users()]


# 物理cpu个数
data["cpu_count"] = psutil.cpu_count(logical=False)
# cpu内核个数
data["cpu_logical_count"] = psutil.cpu_count()
# cpu的使用率
data["cpu_every_percent"] = psutil.cpu_percent(interval=1, percpu=True)  # 每个CPU最近1秒内的使用率
data["cpu_avg_percent"] = psutil.cpu_percent(interval=1, percpu=False)   # 系统所有CPU最近1秒内的平均使用率
# cpu的平均负载
data["cpu_loadavg"] = [str(item) for item in psutil.getloadavg()]

# 查看物理内存信息
data["memory"] = {}
memory = psutil.virtual_memory()
# 总内存
data["memory"]["total"] = size(memory.total)
# 空闲内存
data["memory"]["free"] = size(memory.free)
# 可用内存
data["memory"]["available"] = size(memory.available)
# 系统已用内存的百分比(不包含缓存与缓存),percent=(total - available) / total * 100
data["memory"]["percent1"] = size(memory.percent)
# 系统已用内存的百分比(包含缓存与缓存)
data["memory"]["percent2"] = (memory.total - memory.free) / memory.total

# 查看交换内存信息
data["swap_memory"] = {}
swap = psutil.swap_memory()
data["swap_memory"]["free"] = size(swap.free)
data["swap_memory"]["total"] = size(swap.total)
data["swap_memory"]["swap_percent"] = (swap.total - swap.free) / swap.total

# 获取网卡信息,可以得到得到网卡属性,连接数,当前数据等信息
data["net"] = {}
net = psutil.net_io_counters()
data["net"]["bytes_recv"] = size(net.bytes_recv)
data["net"]["bytes_sent"] = size(net.bytes_sent)
data["net"]["packets_sent"] = size(net.packets_sent)
data["net"]["packets_recv"] = size(net.packets_recv)

# 获取磁盘数据信息
data["disk"] = []
io = psutil.disk_partitions()
for i in io:
    try:
        o = psutil.disk_usage(i.device)
        data["disk"].append({
          "device": i.device,
          "total": size(o.total),
          "used": size(o.used),
          "free": size(o.free),
        })
    except PermissionError:
        continue


# 查看系统全部进程
data["process_list"] = []
for proc in psutil.process_iter(['pid', 'create_time', 'name', 'status', 'memory_percent', 'username', 'cmdline']):
    data["process_list"].append(proc.info)

print(json.dumps(data))

基于echarts图表实现报表效果,效果:

image-20210312194813533

客户端,Monitor.vue,代码:

<template>
  <div class="monitor">
    <div ref="container" style="height: 300px;"></div>
  </div>
</template>

<script setup>

</script>

<style scoped>

</style>

Base.vue中补充地址,代码:

        {id: 7, icon: 'mail', title: '监控预警', tube: '', 'menu_url': '/uric/monitor', children: []},

路由,代码:

import Monitor from "../views/Monitor"

const router = new Router({
  mode: "history",
  routes: [
    //....
    {
      path: '/hippo',
      name: 'Base',
      component: Base,
      children: [
        // ....
        {
          path: 'monitor',
          name: 'Monitor',
          component: Monitor,
        },
      ]
    },

  ]
})


Monitor.vue,代码:

<template>
  <div class="schedule">
    <div class="add_app" style="margin-top: 20px">
      <a-button style="margin-bottom: 20px;" @click="showMonitorModal">新增监控主机</a-button>
    </div>

    <a-modal v-model:visible="MonitorModalVisible" title="新增监控主机" @ok="handOk" ok-text="添加" cancel-text="取消">
      <a-form
        ref="ruleForm"
        :model="form"
        :rules="rules"
        :label-col="labelCol"
        :wrapper-col="wrapperCol"
      >
        <a-form-item ref="task_name" label="任务名称:" prop="task_name">
          <a-input v-model:value="form.task_name"/>
        </a-form-item>
        <a-form-item label="请选择主机:" prop="hosts">
          <a-select
            mode="multiple"
            v-model:value="form.hosts"
            style="width: 100%"
            placeholder="请选择主机"
            @change="handleHostChange"
          >
            <a-select-option v-for="(host_value,host_index) in host_list" :key="host_index" :value="host_value.id">
             {{host_value.ip_addr}}--{{host_value.name}}
            </a-select-option>
          </a-select>
        </a-form-item>
        <a-form-item label="请选择周期方式:" prop="hosts">
          <a-select style="width: 120px" v-model:value="form.period_way" @change="handlePeriodChange">
            <a-select-option v-for="(period_value,period_index) in period_way_choices" :value="period_value[0]" :key="period_index">
              {{period_value[1]}}
            </a-select-option>
          </a-select>
        </a-form-item>
        <a-form-item ref="period_content" label="任务周期值:" prop="period_content">
          <a-input v-model:value="form.period_content" />
        </a-form-item>
        <a-form-item ref="task_cmd" label="任务指令:" prop="task_cmd">
          <v-ace-editor v-model:value="form.task_cmd" lang="html" theme="chrome" style="height: 200px"/>
        </a-form-item>
      </a-form>
    </a-modal>

    <div class="release">
      <div class="app_list">
        <a-table :columns="columns" :data-source="MonitorList" row-key="id">
          <template #bodyCell="{ column, text, record }">
            <template v-if="column.dataIndex === 'action'">
              <a v-if="record.enabled" @click="change_schedule_status(record)">暂停</a>
              <a v-else  @click="change_schedule_status(record)">激活</a>
              <span style="color: lightgray"> | </span>
              <a>删除</a>
            </template>
          </template>
        </a-table>
      </div>
    </div>

  </div>
</template>

<script>
import {ref, reactive, watch} from 'vue';
import axios from "axios";
import settings from "@/settings";
import {message} from 'ant-design-vue';
import store from "@/store";


import {VAceEditor} from 'vue3-ace-editor';
import 'ace-builds/src-noconflict/mode-html';
import 'ace-builds/src-noconflict/theme-chrome';

export default {
  components: {
    VAceEditor,
  },
  setup() {

    // 表格字段列设置
    const columns = [
      {
        title: 'ID',
        dataIndex: 'id',
        key: 'id',
        sorter: true,
        width: 230
      },
      {
        title: '监控主机',
        dataIndex: 'host',
        key: 'host',
        sorter: true,
        width: 150
      },
      {
        title: '操作',
        dataIndex: 'action',
        width: 300,
        key: 'action', scopedSlots: {customRender: 'action'}
      },
    ]


    // 监控主机列表
    const MonitorList = ref([]);

    const get_tasks_list = ()=>{
      axios.get(`${settings.host}/schedule/tasks/`,{
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          MonitorList.value = res.data;
      })
    }

    get_tasks_list();

    const labelCol = reactive({span: 4})
    const wrapperCol = reactive({span: 14})
    const other = ref('')
    const period_way_choices = ref([])  // 所有周期类型数据
    const host_list = ref([]) // 主机列表数据

    const form = reactive({
        task_name: '',
        hosts: [],
        period_way: 1,
        task_cmd:'',
        period_content:'',
    })

    const rules = reactive({
      task_name: [
        {required: true, message: '请输入任务名称', trigger: 'blur'},
      ],
    })

    // 获取主机列表
    const get_host_list = ()=>{
      axios.get(`${settings.host}/host/`,{
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          host_list.value = res.data;
      })
    }

    get_host_list();

    const get_period_data = ()=>{
        axios.get(`${settings.host}/schedule/periods/`).then((res)=>{
          period_way_choices.value = res.data;
          console.log(period_way_choices);
        }).catch((error)=>{

        })
    }

    get_period_data()

    // 是否显示添加监控主机的弹窗
    const MonitorModalVisible = ref(false)
    const showMonitorModal = ()=>{
      MonitorModalVisible.value = true
    }

    const handleHostChange = ()=>{

    }

    // 提交表单
    const handOk = ()=>{
      axios.post(`${settings.host}/schedule/tasks/`,form, {
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          MonitorList.value.unshift(res.data);
      })
    }

    // 切换计划任务的状态
    const change_schedule_status = (record)=>{
      axios.put(`${settings.host}/schedule/tasks/${record.id}/`,{}, {
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          record.enabled = !record.enabled;
      })
    }

    return {
      columns,
      labelCol,
      wrapperCol,
      other,
      period_way_choices,
      host_list,
      form,
      rules,
      MonitorList,
      MonitorModalVisible,
      showMonitorModal,
      handleHostChange,
      handOk,
      change_schedule_status,
    }
  }
}
</script>

<style scoped>

</style>

创建应用

cd uric_api/apps
python ../../manage.py startapp monitor

settings.py配置应用

INSTALLED_APPS = [
    ...
    'monitor',
]

总路由

path('monitor/', include('monitor.urls')),

monitor/urls.py

from django.urls import path, re_path
from . import  views

urlpatterns = [
    
]

模型创建,monitor/models.py,代码:

from django.db import models
from host.models import Host
from uric_api.utils.models import BaseModel

class MonitorParams(BaseModel):
    """监控参数类型"""
    class Meta:
        db_table = "monitor_params"
        verbose_name = "监控参数类型"
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.name


class MonitorHost(models.Model):
    """监控的主机列表"""
    host = models.ForeignKey(Host, on_delete=models.DO_NOTHING, default=1, verbose_name='主机')
    times = models.CharField(max_length=255, verbose_name="时间间隔")

    class Meta:
        db_table = "monitor_host"
        verbose_name = "监控主机列表"
        verbose_name_plural = verbose_name

数据迁移

cd 服务端项目根目录下
python manage.py makemigrations
python manage.py migrate

添加监测参数类型

INSERT INTO uric.monitor_params (id, name, is_show, orders, is_deleted, created_time, updated_time, description) VALUES (1, 'cpu', 1, 0, 0, '2022-10-15 15:13:01', '2022-10-15 15:13:01', 'CPU');
INSERT INTO uric.monitor_params (id, name, is_show, orders, is_deleted, created_time, updated_time, description) VALUES (2, 'memory', 1, 0, 0, '2022-10-15 15:13:01', '2022-10-15 15:13:01', '内存');
INSERT INTO uric.monitor_params (id, name, is_show, orders, is_deleted, created_time, updated_time, description) VALUES (3, 'disk', 1, 0, 0, '2022-10-15 15:13:01', '2022-10-15 15:13:01', '硬盘');

通知

在平台上,针对参数变化,达到预设的指定阈值以后,我们就需要发送通知给指定的管理员,可以选择实现多种不同的通知类型,例如:短信、邮件、钉钉、飞书、微信等等。。

短信发送

开发中都是到第三方短信平台购买套餐,常见的第三方短信平台:腾讯、阿里、华为、容联云等等。使用容联云第三方进行短信发送。

官方网站:https://www.yuntongxun.com/user/login

官方文档:http://doc.yuntongxun.com/space/5a5098313b8496dd00dcdd7f

1615705610571

在登录后的平台上面获取一下信息:[https://www.yuntongxun.com/]

ACCOUNT SID:8aaf07086f17620f016f308d0d2c0fa9
AUTH TOKEN : be8d96030fca44ffaf958062e6c658e8
AppID(默认):8aaf07086f17620f016f308d0d850faf

https://www.yuntongxun.com/doc.html

1615705656076

流程

http://doc.yuntongxun.com/space/5a5098313b8496dd00dcdd7f

1615705685200

找到sdkdemo进行下载

1615705698493

文档和sdk下载地址:https://doc.yuntongxun.com/p/5f029ae7a80948a1006e776e

安装短信的sdk,python模块

pip install ronglian_sms_sdk

测试代码,demo/发送短信.py,代码:

import json
import random
from ronglian_sms_sdk import SmsSDK

# 配置文件 settings.py
accId = '8a216da863f8e6c20164139687e80c1b'
accToken = '6dd01b2b60104b3dbc88b2b74158bac6'
appId = '8a216da863f8e6c20164139688400c21'
templateId = 1

# 工具函数 utils/
def send_message(mobile, datas, tid=None):
    sdk = SmsSDK(accId, accToken, appId)
    tid = templateId if tid is None else tid
    resp = sdk.sendMessage(tid, mobile, datas)
    response = json.loads(resp)
    return response


if __name__ == '__main__':
    code = f"{random.randint(100000,999999):06d}"
    resp = send_message('13928835901', (code, 5))
    print(resp)

邮件发送

可以基于django本身内容的email模块来发送。

from django.core import mail

settings.dev,代码:

# 邮件发送配置
EMAIL_HOST_USER = "13928835901@163.com"
EMAIL_HOST_PASSWORD  = "XOLWYFIMEREAWCOU"
EMAIL_HOST = "smtp.163.com"
EMAIL_PORT = 465
EMAIL_USE_SSL = True

测试发送邮件,代码:

import os, django
from django.core import mail

if __name__ == '__main__':
    # django初始化
    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'uric_api.settings.dev')
    django.setup()

    # mail.send_mail(
    #     subject="测试标题",
    #     message="邮件文本内容[不包含html内容]",
    #     from_email="发件人的邮箱地址",
    #     recipient_list="收件人的邮箱列表",
    #     html_message="邮件HTML内容")

    # 其中,message与html_message是互斥的,只能指定其中一个参数
    mail.send_mail(
        subject="测试标题",
        message="邮件文本内容[不包含html内容]",
        from_email="13928835901@163.com",
        recipient_list=["13928835901@163.com"])

修改模型,添加通知相关的字段,monitor/models.py,代码:

from django.db import models
from host.models import Host
from uric_api.utils.models import BaseModel


class MonitorParams(BaseModel):
    """监控参数类型"""
    class Meta:
        db_table = "monitor_params"
        verbose_name = "监控参数类型"
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.name


class MonitorHost(models.Model):
    """监控的主机列表"""
    NOTIFICATION_TYPE = (
        (0, "邮件通知"),
        (1, "短信通知"),
        (2, "微信通知"),
        (3, "钉钉通知"),
    )
    host = models.ForeignKey(Host, on_delete=models.DO_NOTHING, verbose_name='主机')
    times = models.CharField(max_length=255, verbose_name="时间间隔")
    param = models.ForeignKey(MonitorParams, default=None, null=True, blank=True, on_delete=models.DO_NOTHING, verbose_name="监控参数")
    value = models.FloatField(default=0, null=True, blank=True, verbose_name="预警阈值")
    notification_type = models.SmallIntegerField(choices=NOTIFICATION_TYPE, default=0, verbose_name="通知类型")
    notification_info = models.CharField(default=None, null=True, blank=True, max_length=500, verbose_name="通知人")

    class Meta:
        db_table = "monitor_host"
        verbose_name = "监控主机列表"
        verbose_name_plural = verbose_name

重新数据迁移,

python manage.py makemigrations
python manage.py migrate

功能实现

序列化器,monitor/serializer.py,代码:

from rest_framework import serializers
from .models import MonitorParams, MonitorHost

class MonitorParamsModelSerializer(serializers.ModelSerializer):
    """监控参数类型的序列化器"""
    class Meta:
        model = MonitorParams
        fields = ["id", "name", "description"]


class MonitorHostModelSerlaizer(serializers.ModelSerializer):
    """监控主机的序列化器"""
    host_name = serializers.CharField(source="host.name", read_only=True)
    host_ip_addr = serializers.CharField(source="host.ip_addr", read_only=True)
    host_port = serializers.IntegerField(source="host.port", read_only=True)
    param_name = serializers.CharField(source="param.name", read_only=True)
    param_description = serializers.CharField(source="param.description", read_only=True)

    class Meta:
        model = MonitorHost
        fields = ["id", "host", "host_name", "host_ip_addr", "host_port", "times", "param", "param_name", "param_description", "value", "notification_type", "get_notification_type_display", "notification_info"]

视图,monitor/views.py,代码:

from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
from rest_framework.response import Response
from .models import MonitorHost, MonitorParams
from .serializers import MonitorParamsModelSerializer, MonitorHostModelSerlaizer


# Create your views here.
class MonitorParamViewSet(ModelViewSet):
    queryset = MonitorParams.objects.all()
    serializer_class = MonitorParamsModelSerializer


class NotificationTypeAPIView(APIView):
    def get(self, request):
        """获取监控的通知类型"""
        return Response(MonitorHost.NOTIFICATION_TYPE)

class MonitorHostViewSet(ModelViewSet):
    queryset = MonitorHost.objects.all()
    serializer_class = MonitorHostModelSerlaizer

路由,monitor/urls.py,代码:

from django.urls import path, re_path
from . import  views
from rest_framework.routers import DefaultRouter

router = DefaultRouter()
router.register("param", views.MonitorParamViewSet, basename="param")
router.register("host", views.MonitorHostViewSet, basename="host")

urlpatterns = [
    path("notif/", views.NotificationTypeAPIView.as_view()),
] + router.urls

客户端实现监控主机列表信息,views/Monitor.vue,代码:

<template>
  <div class="schedule">
    <div class="add_app" style="margin-top: 20px">
      <a-button style="margin-bottom: 20px;" @click="showMonitorModal">新增监控主机</a-button>
    </div>

    <a-modal v-model:visible="MonitorModalVisible" title="新增监控主机" @ok="handOk" ok-text="添加" cancel-text="取消">
      <a-form
        ref="ruleForm"
        :model="form"
        :rules="rules"
        :label-col="labelCol"
        :wrapper-col="wrapperCol"
      >
        <a-form-item label="主机列表:" prop="hosts">
          <a-select
            mode="multiple"
            v-model:value="form.hosts"
            style="width: 100%"
            placeholder="请选择要监控主机"
            @change="handleHostChange"
          >
            <a-select-option v-for="(host_value,host_index) in host_list" :key="host_index" :value="host_value.id">
             {{host_value.ip_addr}}--{{host_value.name}}
            </a-select-option>
          </a-select>
        </a-form-item>
        <a-form-item label="监控参数:" prop="param">
          <a-select style="width: 120px" v-model:value="form.param" placeholder="请选择要监控的参数类型">
            <a-select-option v-for="(param,key) in monitor_param_choices" :value="param.id" :key="param.id">
              {{param.description}}
            </a-select-option>
          </a-select>
        </a-form-item>
        <a-form-item ref="value" label="报警阈值:" prop="value">
          <a-input v-model:value="form.value" style="width: 60px;"/> %
        </a-form-item>
        <a-form-item label="通知类型:" prop="notification_type">
          <a-select style="width: 120px" v-model:value="form.notification_type" placeholder="请选择要监控的参数类型">
            <a-select-option v-for="(notification,index) in notification_type_choices" :value="index" :key="index">
              {{notification[1]}}
            </a-select-option>
          </a-select>
        </a-form-item>
        <a-form-item ref="notification_info" label="通知人列表:" prop="notification_info">
          <a-input v-model:value="form.notification_info" />
        </a-form-item>
        <a-form-item ref="times" label="时间间隔:" prop="times">
          <a-input v-model:value="form.times" />
        </a-form-item>
      </a-form>
    </a-modal>

    <div class="release">
      <div class="app_list">
        <a-table :columns="columns" :data-source="MonitorList" row-key="id">
          <template #bodyCell="{ column, text, record }">
            <template v-if="column.dataIndex === 'action'">
              <a>查看</a>
              <span style="color: lightgray"> | </span>
              <a>删除</a>
            </template>
          </template>
        </a-table>
      </div>
    </div>

  </div>
</template>

<script>
import {ref, reactive} from 'vue';
import axios from "axios";
import settings from "@/settings";
import {message} from 'ant-design-vue';
import store from "@/store";

export default {
  setup() {
    // 表格字段列设置
    const columns = [
      {
        title: 'ID',
        dataIndex: 'id',
        key: 'id',
        sorter: true,
        width: 100
      },
      {
        title: '监控主机',
        dataIndex: 'host',
        key: 'host',
        sorter: true,
        width: 150
      },
      {
        title: '通知类型',
        dataIndex: 'notification',
        key: 'notification',
        sorter: true,
        width: 150
      },
      {
        title: '监控参数',
        dataIndex: 'param',
        key: 'param',
        sorter: true,
        width: 150
      },
      {
        title: '操作',
        dataIndex: 'action',
        width: 300,
        key: 'action', scopedSlots: {customRender: 'action'}
      },
    ]


    // 监控主机列表
    const MonitorList = ref([]);

    const get_tasks_list = ()=>{
      axios.get(`${settings.host}/monitor/host/`,{
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
        let infoList = []
        for (const item of res.data) {
          infoList.push({
            id: item.id,
            notification: item.get_notification_type_display,
            host_id: item.host,
            host: `${item.host_name}[${item.host_ip_addr}:${item.host_port}]`,
            param: item.param_description,
          });
        }
          MonitorList.value = infoList;
      })
    }

    get_tasks_list();

    const labelCol = reactive({span: 4})
    const wrapperCol = reactive({span: 14})
    const other = ref('')
    const monitor_param_choices = ref([])  // 所有监控参数类型数据
    const host_list = ref([]) // 主机列表数据

    const form = reactive({
        hosts: [],
        param: 1,
        value: 0,
        notification_type:0,
        notification_info: '',
        times: 60,
    })

    const rules = reactive({
      task_name: [
        {required: true, message: '请输入任务名称', trigger: 'blur'},
      ],
    })

    // 获取主机列表
    const get_host_list = ()=>{
      axios.get(`${settings.host}/host/`,{
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          host_list.value = res.data;
      })
    }

    get_host_list();

    // 获取监控参数类型
    const get_monitor_param = ()=>{
      axios.get(`${settings.host}/monitor/param/`,{
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          monitor_param_choices.value = res.data;
      })
    }
    get_monitor_param()

    // 获取监控参数类型
    const notification_type_choices = ref([])
    const get_notification_type = ()=>{
      axios.get(`${settings.host}/monitor/notif/`,{
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          notification_type_choices.value = res.data;
      })
    }
    get_notification_type()

    // 是否显示添加监控主机的弹窗
    const MonitorModalVisible = ref(false)
    const showMonitorModal = ()=>{
      MonitorModalVisible.value = true
    }

    const handleHostChange = ()=>{

    }

    // 提交表单
    const handOk = ()=>{
      console.log(form)
      // axios.post(`${settings.host}/schedule/tasks/`,form, {
      //   headers:{
      //     Authorization: "jwt " + store.getters.token
      //   }
      // }).then((res) => {
      //     MonitorList.value.unshift(res.data);
      // })
    }



    return {
      columns,
      labelCol,
      wrapperCol,
      other,
      monitor_param_choices,
      notification_type_choices,
      host_list,
      form,
      rules,
      MonitorList,
      MonitorModalVisible,
      showMonitorModal,
      handleHostChange,
      handOk,
    }
  }
}
</script>

<style scoped>

</style>

实现监控主机信息的添加,views/Monitor.vue,代码:

<template>
  <div class="schedule">
    <div class="add_app" style="margin-top: 20px">
      <a-button style="margin-bottom: 20px;" @click="showMonitorModal">新增监控主机</a-button>
    </div>

    <a-modal v-model:visible="MonitorModalVisible" title="新增监控主机" @ok="handOk" ok-text="添加" cancel-text="取消">
      <a-form
        ref="ruleForm"
        :model="form"
        :rules="rules"
        :label-col="labelCol"
        :wrapper-col="wrapperCol"
      >
        <a-form-item label="主机列表:" prop="hosts">
          <a-select
            mode="multiple"
            v-model:value="form.hosts"
            style="width: 100%"
            placeholder="请选择要监控主机"
          >
            <a-select-option v-for="(host_value,host_index) in host_list" :key="host_index" :value="host_value.id">
             {{host_value.ip_addr}}--{{host_value.name}}
            </a-select-option>
          </a-select>
        </a-form-item>
        <a-form-item label="监控参数:" prop="param">
          <a-select style="width: 120px" v-model:value="form.param" placeholder="请选择要监控的参数类型">
            <a-select-option v-for="(param,key) in monitor_param_choices" :value="param.id" :key="param.id">
              {{param.description}}
            </a-select-option>
          </a-select>
        </a-form-item>
        <a-form-item ref="value" label="报警阈值:" prop="value">
          <a-input v-model:value="form.value" style="width: 60px;"/> %
        </a-form-item>
        <a-form-item label="通知类型:" prop="notification_type">
          <a-select style="width: 120px" v-model:value="form.notification_type" placeholder="请选择要监控的参数类型">
            <a-select-option v-for="(notification,index) in notification_type_choices" :value="index" :key="index">
              {{notification[1]}}
            </a-select-option>
          </a-select>
        </a-form-item>
        <a-form-item ref="notification_info" label="通知人列表:" prop="notification_info">
          <a-input v-model:value="form.notification_info" />
        </a-form-item>
        <a-form-item ref="times" label="时间间隔:" prop="times">
          <a-input v-model:value="form.times" />
        </a-form-item>
      </a-form>
    </a-modal>

    <div class="release">
      <div class="app_list">
        <a-table :columns="columns" :data-source="MonitorList" row-key="id">
          <template #bodyCell="{ column, text, record }">
            <template v-if="column.dataIndex === 'action'">
              <a>查看</a>
              <span style="color: lightgray"> | </span>
              <a>删除</a>
            </template>
          </template>
        </a-table>
      </div>
    </div>

  </div>
</template>

<script>
import {ref, reactive} from 'vue';
import axios from "axios";
import settings from "@/settings";
import {message} from 'ant-design-vue';
import store from "@/store";

export default {
  setup() {
    // 表格字段列设置
    const columns = [
      {
        title: 'ID',
        dataIndex: 'id',
        key: 'id',
        sorter: true,
        width: 100
      },
      {
        title: '监控主机',
        dataIndex: 'host',
        key: 'host',
        sorter: true,
        width: 150
      },
      {
        title: '通知类型',
        dataIndex: 'notification',
        key: 'notification',
        sorter: true,
        width: 150
      },
      {
        title: '监控参数',
        dataIndex: 'param',
        key: 'param',
        sorter: true,
        width: 150
      },
      {
        title: '操作',
        dataIndex: 'action',
        width: 300,
        key: 'action', scopedSlots: {customRender: 'action'}
      },
    ]


    // 监控主机列表
    const MonitorList = ref([]);

    const get_tasks_list = ()=>{
      axios.get(`${settings.host}/monitor/host/`,{
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
        let infoList = []
        for (const item of res.data) {
          infoList.push({
            id: item.id,
            notification: item.get_notification_type_display,
            host_id: item.host,
            host: `${item.host_name}[${item.host_ip_addr}:${item.host_port}]`,
            param: item.param_description,
          });
        }
          MonitorList.value = infoList;
      })
    }

    get_tasks_list();

    const labelCol = reactive({span: 4})
    const wrapperCol = reactive({span: 14})
    const other = ref('')
    const monitor_param_choices = ref([])  // 所有监控参数类型数据
    const host_list = ref([]) // 主机列表数据

    const form = reactive({
        hosts: [],
        param: 1,
        value: 0,
        notification_type:0,
        notification_info: '',
        times: 60,
    })

    const rules = reactive({
      task_name: [
        {required: true, message: '请输入任务名称', trigger: 'blur'},
      ],
    })

    // 获取主机列表
    const get_host_list = ()=>{
      axios.get(`${settings.host}/host/`,{
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          host_list.value = res.data;
      })
    }

    get_host_list();

    // 获取监控参数类型
    const get_monitor_param = ()=>{
      axios.get(`${settings.host}/monitor/param/`,{
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          monitor_param_choices.value = res.data;
      })
    }
    get_monitor_param()

    // 获取监控参数类型
    const notification_type_choices = ref([])
    const get_notification_type = ()=>{
      axios.get(`${settings.host}/monitor/notif/`,{
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          notification_type_choices.value = res.data;
      })
    }
    get_notification_type()

    // 是否显示添加监控主机的弹窗
    const MonitorModalVisible = ref(false)
    const showMonitorModal = ()=>{
      MonitorModalVisible.value = true
    }

    // 提交表单
    const handOk = ()=>{
      let hosts = form.hosts
      delete form.hosts
      console.log(form)
      for (const host of hosts) {
        form['host'] = host
        axios.post(`${settings.host}/monitor/host/`,form, {
          headers:{
            Authorization: "jwt " + store.getters.token
          }
        }).then((res) => {
            message.success("添加监控主机信息成功!")
            MonitorModalVisible.value = false
            MonitorList.value.unshift({
            id: res.data.id,
            notification: res.data.get_notification_type_display,
            host_id: res.data.host,
            host: `${res.data.host_name}[${res.data.host_ip_addr}:${res.data.host_port}]`,
            param: res.data.param_description,
          });
        })
      }
    }



    return {
      columns,
      labelCol,
      wrapperCol,
      other,
      monitor_param_choices,
      notification_type_choices,
      host_list,
      form,
      rules,
      MonitorList,
      MonitorModalVisible,
      showMonitorModal,
      handOk,
    }
  }
}
</script>

<style scoped>

</style>

客户端展示监控器窗口,views/MonitorWindow.vue,代码:

<template>
  <div class="chart" ref="echartDiv" style="width: 800px; height: 350px;"></div>
</template>

<script>
import {ref, reactive, onMounted} from 'vue';
import * as echarts from 'echarts';

export default {
  name: "MonitorWindow",
  setup(){
    const echartDiv = ref()
    const host_monitor_data = ref([])
    const get_data = ()=>{
      host_monitor_data.value = [
          {name: "2022-11-11 11:01:00", "value": ["2022-11-11 11:01:00", 10]},
          {name: "2022-11-11 11:01:30", "value": ["2022-11-11 11:01:30", 20]},
          {name: "2022-11-11 11:02:00", "value": ["2022-11-11 11:02:00", 30]},
          {name: "2022-11-11 11:02:30", "value": ["2022-11-11 11:02:30", 40]},
          {name: "2022-11-11 11:03:00", "value": ["2022-11-11 11:03:00", 50]},
          {name: "2022-11-11 11:03:30", "value": ["2022-11-11 11:03:30", 30]},
      ]
    }

    get_data();

    const showEchart = ()=>{
      let myChart = echarts.init(echartDiv.value);
      let option = {
        title: {
          text: '监控显示器'
        },
        tooltip: {
          trigger: 'axis',
          formatter(params) {
            params = params[0];
            let date = new Date(params.name);
            return (
              date.getDate() +
              '/' +
              (date.getMonth() + 1) +
              '/' +
              date.getFullYear() +
              ' ' +
              date.getHours() +
              ':' +
              date.getMinutes() +
              ':' +
              date.getSeconds() +
              '  ' +
              params.value[1]
            );
          },
          axisPointer: {
            animation: false
          }
        },
        xAxis: {
          type: 'time',
          splitLine: {
            show: false
          }
        },
        yAxis: {
          type: 'value',
          boundaryGap: [0, '100%'],
          splitLine: {
            show: false
          }
        },
        series: [
          {
            name: 'Fake Data',
            type: 'line',
            showSymbol: false,
            data: host_monitor_data.value
          }
        ]
      };
      option && myChart.setOption(option);
    }

    onMounted(()=>{
      showEchart()
    })

    return {
      echartDiv,
    }
  }
}
</script>

<style scoped>

</style>

views/Monitor.vue,代码:

<template>
  <div class="schedule">
    <div class="add_app" style="margin-top: 20px">
      <a-button style="margin-bottom: 20px;" @click="showMonitorModal">新增监控主机</a-button>
    </div>

    <a-modal v-model:visible="MonitorModalVisible" title="新增监控主机" @ok="handOk" ok-text="添加" cancel-text="取消">
      <a-form
        ref="ruleForm"
        :model="form"
        :rules="rules"
        :label-col="labelCol"
        :wrapper-col="wrapperCol"
      >
        <a-form-item label="主机列表:" prop="hosts">
          <a-select
            mode="multiple"
            v-model:value="form.hosts"
            style="width: 100%"
            placeholder="请选择要监控主机"
          >
            <a-select-option v-for="(host_value,host_index) in host_list" :key="host_index" :value="host_value.id">
             {{host_value.ip_addr}}--{{host_value.name}}
            </a-select-option>
          </a-select>
        </a-form-item>
        <a-form-item label="监控参数:" prop="param">
          <a-select style="width: 120px" v-model:value="form.param" placeholder="请选择要监控的参数类型">
            <a-select-option v-for="(param,key) in monitor_param_choices" :value="param.id" :key="param.id">
              {{param.description}}
            </a-select-option>
          </a-select>
        </a-form-item>
        <a-form-item ref="value" label="报警阈值:" prop="value">
          <a-input v-model:value="form.value" style="width: 60px;"/> %
        </a-form-item>
        <a-form-item label="通知类型:" prop="notification_type">
          <a-select style="width: 120px" v-model:value="form.notification_type" placeholder="请选择要监控的参数类型">
            <a-select-option v-for="(notification,index) in notification_type_choices" :value="index" :key="index">
              {{notification[1]}}
            </a-select-option>
          </a-select>
        </a-form-item>
        <a-form-item ref="notification_info" label="通知人列表:" prop="notification_info">
          <a-input v-model:value="form.notification_info" />
        </a-form-item>
        <a-form-item ref="times" label="时间间隔:" prop="times">
          <a-input v-model:value="form.times" />
        </a-form-item>
      </a-form>
    </a-modal>

    <div class="release">
      <div class="app_list">
        <a-table :columns="columns" :data-source="MonitorList" row-key="id">
          <template #bodyCell="{ column, text, record }">
            <template v-if="column.dataIndex === 'action'">
              <a @click="showMonitorInfoModal(record)">查看</a>
              <span style="color: lightgray"> | </span>
              <a>删除</a>
            </template>
          </template>
        </a-table>
      </div>
    </div>
    <a-modal v-model:visible="MonitorVisible" :wrap-style="{ overflow: 'hidden' }" width="800px" title="监控窗口" @ok="handleOk">
      <MonitorWindow></MonitorWindow>
    </a-modal>

  </div>
</template>

<script>
import {ref, reactive} from 'vue';
import axios from "axios";
import settings from "@/settings";
import {message} from 'ant-design-vue';
import store from "@/store";
import MonitorWindow from "@/views/MonitorWindow";

export default {
  components: {
    MonitorWindow
  },
  setup() {
    // 表格字段列设置
    const columns = [
      {
        title: 'ID',
        dataIndex: 'id',
        key: 'id',
        sorter: true,
        width: 100
      },
      {
        title: '监控主机',
        dataIndex: 'host',
        key: 'host',
        sorter: true,
        width: 150
      },
      {
        title: '通知类型',
        dataIndex: 'notification',
        key: 'notification',
        sorter: true,
        width: 150
      },
      {
        title: '监控参数',
        dataIndex: 'param',
        key: 'param',
        sorter: true,
        width: 150
      },
      {
        title: '操作',
        dataIndex: 'action',
        width: 300,
        key: 'action', scopedSlots: {customRender: 'action'}
      },
    ]


    // 监控主机列表
    const MonitorList = ref([]);

    const get_tasks_list = ()=>{
      axios.get(`${settings.host}/monitor/host/`,{
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
        let infoList = []
        for (const item of res.data) {
          infoList.push({
            id: item.id,
            notification: item.get_notification_type_display,
            host_id: item.host,
            host: `${item.host_name}[${item.host_ip_addr}:${item.host_port}]`,
            param: item.param_description,
          });
        }
          MonitorList.value = infoList;
      })
    }

    get_tasks_list();

    const labelCol = reactive({span: 4})
    const wrapperCol = reactive({span: 14})
    const other = ref('')
    const monitor_param_choices = ref([])  // 所有监控参数类型数据
    const host_list = ref([]) // 主机列表数据

    const form = reactive({
        hosts: [],
        param: 1,
        value: 0,
        notification_type:0,
        notification_info: '',
        times: 60,
    })

    const rules = reactive({
      task_name: [
        {required: true, message: '请输入任务名称', trigger: 'blur'},
      ],
    })

    // 获取主机列表
    const get_host_list = ()=>{
      axios.get(`${settings.host}/host/`,{
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          host_list.value = res.data;
      })
    }

    get_host_list();

    // 获取监控参数类型
    const get_monitor_param = ()=>{
      axios.get(`${settings.host}/monitor/param/`,{
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          monitor_param_choices.value = res.data;
      })
    }
    get_monitor_param()

    // 获取监控参数类型
    const notification_type_choices = ref([])
    const get_notification_type = ()=>{
      axios.get(`${settings.host}/monitor/notif/`,{
        headers:{
          Authorization: "jwt " + store.getters.token
        }
      }).then((res) => {
          notification_type_choices.value = res.data;
      })
    }
    get_notification_type()

    // 是否显示添加监控主机的弹窗
    const MonitorModalVisible = ref(false)
    const showMonitorModal = ()=>{
      MonitorModalVisible.value = true
    }

    // 提交表单
    const handOk = ()=>{
      let hosts = form.hosts
      delete form.hosts
      console.log(form)
      for (const host of hosts) {
        form['host'] = host
        axios.post(`${settings.host}/monitor/host/`,form, {
          headers:{
            Authorization: "jwt " + store.getters.token
          }
        }).then((res) => {
            message.success("添加监控主机信息成功!")
            MonitorModalVisible.value = false
            MonitorList.value.unshift({
            id: res.data.id,
            notification: res.data.get_notification_type_display,
            host_id: res.data.host,
            host: `${res.data.host_name}[${res.data.host_ip_addr}:${res.data.host_port}]`,
            param: res.data.param_description,
          });
        })
      }
    }

    // 控制是否显示监控窗口
    const MonitorVisible = ref(false)
    const showMonitorInfoModal = (record)=>{
      console.log("显示的监控信息:", record);
      MonitorVisible.value = true;
    }

    return {
      columns,
      labelCol,
      wrapperCol,
      other,
      monitor_param_choices,
      notification_type_choices,
      host_list,
      form,
      rules,
      MonitorList,
      MonitorModalVisible,
      showMonitorModal,
      handOk,
      MonitorVisible,
      showMonitorInfoModal,
    }
  }
}
</script>

<style scoped>

</style>

接下来,我们就可以在服务端提供对应的监控参数给客户端了。

具体实现思路如下:

image-20221015182946250

在视图中基于sftp上传监控脚本到目标主机

目标主机上传监控数据的方案:

  1. 基于monitor.py脚本实现上传参数,可以通过安装python的第三方模块requests或者python内置的urllib模块
    pip install requests
  2. 基于monitor.sh脚本实现上传参数,可以通过curl 或者 wget 使用http协议上传数据到django中

在ssh工具类中实现上传文件的方法

from paramiko.client import SSHClient, AutoAddPolicy
from paramiko.rsakey import RSAKey
from paramiko.ssh_exception import AuthenticationException, SSHException
from io import StringIO
from paramiko.ssh_exception import NoValidConnectionsError


class SSHParamiko(object):
    def __init__(self, hostname, port=22, username='root', pkey=None, password=None, connect_timeout=3):
        if pkey is None and password is None:
            raise SSHException('私钥或者密码必须选择传入一个')

        self.client = None

        self.params = {
            'hostname': hostname,
            'port': port,
            'username': username,
            'password': password,
            'pkey': RSAKey.from_private_key(StringIO(pkey)) if isinstance(pkey, str) else pkey,
            'timeout': connect_timeout,
        }

    # 检测连接并获取连接
    def get_connected_client(self):
        if self.client is not None:
            # 告知当前执行上下文,self.client已经实例化
            raise RuntimeError('已经建立连接了!!!')

        if not self.client:
            try:
                # 创建客户端连接对象
                self.client = SSHClient()
                # 在本机第一次连接远程主机时记录指纹信息
                self.client.set_missing_host_key_policy(AutoAddPolicy)
                # 建立连接: 口令密码或者密钥
                self.client.connect(**self.params)
            except (TimeoutError, NoValidConnectionsError, AuthenticationException) as e:
                return None

        return self.client

    @staticmethod
    def gen_key():
        # 生成公私钥键值对
        iodata = StringIO()
        key = RSAKey.generate(2048)  # 生成长度为2024的秘钥对
        key.write_private_key(iodata)
        # 返回值是一个元祖,两个成员分别是私钥和公钥
        return iodata.getvalue(), 'ssh-rsa ' + key.get_base64()

    # 将公钥上传到对应主机
    def upload_key(self, public_key):
        print("self.client:::", self.client)
        # 700 是文档拥有可读可写可执行,同一组用户或者其他用户都不具有操作权限
        # 600 是文件拥有者可读可写,不可执行,同一组用户或者其他用户都不具有操作权限
        cmd = f'mkdir -p -m 700 ~/.ssh && \
            echo {public_key!r} >> ~/.ssh/authorized_keys && \
            chmod 600 ~/.ssh/authorized_keys'
        code, out = self.execute_cmd(cmd)
        print("out", out)
        if code != 0:
            raise Exception(f'添加公钥失败: {out}')

    def execute_cmd(self, cmd, timeout=1800):
        # 设置执行指令过程,一旦遇到错误/异常,则直接退出操作,不再继续执行。
        cmd = 'set -e\n' + cmd
        channel = self.client.get_transport().open_session()
        channel.settimeout(timeout)
        channel.set_combine_stderr(True)  # 正确和错误输出都在一个管道对象里面输出出来
        channel.exec_command(cmd)
        try:
            out_data = channel.makefile("rb", -1).read().decode()
        except UnicodeDecodeError:
            out_data = channel.makefile("rb", -1).read().decode("GBK")

        return channel.recv_exit_status(), out_data

    def upload_file(self, fl, remote_path):
        """
        上传文件到远程主机
        :param fl: 本地文件的句柄对象  fl = open()
        :param remote_path: 远程主机保存文件的路径与文件名
        :return:
        """
        sftp = self.client.open_sftp()
        return sftp.putfo(fl, remote_path)

    def download_file(self, remote_path, local_path):
        """
        从远程主机下载文件到本地
        :param remote_path: 远程主机要下载的文件的路径+文件名
        :param local_path: 本地保存下载文件的路径地址
        :return:
        """
        sftp = self.client.open_sftp()
        return sftp.get(remote_path, local_path)

    def list_dir_attr(self, remote_path):
        """
        列出远程主机的指定路径下所有的文件/目录的相关信息
        模拟:ls -l 命令
        :param remote_path:
        :return:
        """
        sftp = self.client.open_sftp()
        return sftp.listdir_attr(remote_path)

    def remove_file(self, remote_path):
        """
        删除指定文件
        :param remote_path: 远程主机上要删除文件的路径+文件名,如果没有对应文件会报错,有返回None
        :return:
        """
        sftp = self.client.open_sftp()
        return sftp.remove(remote_path)

    # 删除远程主机上的目录
    def remove_dir(self, remote_path):
        """
        删除空目录
        :param remote_path: 远程主机上要删除的空目录的路径
        :return:
        """
        sftp = self.client.open_sftp()
        sftp.rmdir(remote_path)

配置中新增监控脚本的路径,settings/dev.py,代码:

# 监控脚本的路径
MONITOR_SCRIPT = BASE_DIR.parent / "scripts/monitor.py"
REMOTE_MONITOR_SCRIPT_PATH = "~/monitor.py"

视图,monitor.py,代码:

from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
from rest_framework.response import Response
from .models import MonitorHost, MonitorParams
from .serializers import MonitorParamsModelSerializer, MonitorHostModelSerlaizer
from host.models import Host
from uric_api.utils.key import PkeyManager
from django.conf import settings
from uric_api.utils.ssh import SSHParamiko

# Create your views here.
class MonitorParamViewSet(ModelViewSet):
    """监控参数类型"""
    queryset = MonitorParams.objects.all()
    serializer_class = MonitorParamsModelSerializer


class NotificationTypeAPIView(APIView):
    """获取监控的通知类型"""
    def get(self, request):
        return Response(MonitorHost.NOTIFICATION_TYPE)

class MonitorHostViewSet(ModelViewSet):
    """监控主机列表"""
    queryset = MonitorHost.objects.all()
    serializer_class = MonitorHostModelSerlaizer

    def create(self, request, *arg, **kwargs):
        # 根据上传的主机ID,使用parmiko基于ssh协议上传监控监本
        host_id = request.data.get("host")
        host = Host.objects.filter(id=host_id).first()
        pkey, _ = PkeyManager.get(settings.DEFAULT_KEY_NAME)  # 获取ssh秘钥
        ssh = SSHParamiko(host.ip_addr, host.port, host.username, pkey)
        ssh.get_connected_client()
        # 基于ssh上传文件
        fl = open(settings.MONITOR_SCRIPT, "rb")
        ssh.upload_file(fl, settings.REMOTE_MONITOR_SCRIPT_PATH.replace("~", ssh.execute_cmd("cd ~ && pwd")[1][:-1]))
        return super().create(request, *arg, **kwargs)

服务端提提供视图给远程主机上传监控数据

monitor/view.py,代码:


监控脚本调用requests请求请求django保存监控数据

客户端定时ajax请求服务端的监控数据

posted @ 2023-11-23 22:37  凫弥  阅读(50)  评论(3编辑  收藏  举报