├── .dockerignore ├── .gitattributes ├── .gitignore ├── Dockerfile ├── Dockerfile.release ├── LICENSE ├── README.md ├── assets └── screenshot │ ├── notification.png │ ├── scheduler.png │ └── task.png ├── cmd ├── gocron │ └── gocron.go └── node │ └── node.go ├── go.mod ├── go.sum ├── internal ├── models │ ├── host.go │ ├── login_log.go │ ├── migration.go │ ├── model.go │ ├── setting.go │ ├── task.go │ ├── task_host.go │ ├── task_log.go │ └── user.go ├── modules │ ├── app │ │ └── app.go │ ├── httpclient │ │ └── http_client.go │ ├── logger │ │ └── logger.go │ ├── notify │ │ ├── mail.go │ │ ├── notify.go │ │ ├── slack.go │ │ └── webhook.go │ ├── rpc │ │ ├── auth │ │ │ └── Certification.go │ │ ├── client │ │ │ └── client.go │ │ ├── grpcpool │ │ │ └── grpc_pool.go │ │ ├── proto │ │ │ ├── task.pb.go │ │ │ └── task.proto │ │ └── server │ │ │ └── server.go │ ├── setting │ │ └── setting.go │ └── utils │ │ ├── json.go │ │ ├── utils.go │ │ ├── utils_test.go │ │ ├── utils_unix.go │ │ └── utils_windows.go ├── routers │ ├── base │ │ └── base.go │ ├── host │ │ └── host.go │ ├── install │ │ └── install.go │ ├── loginlog │ │ └── login_log.go │ ├── manage │ │ └── manage.go │ ├── routers.go │ ├── task │ │ └── task.go │ ├── tasklog │ │ └── task_log.go │ └── user │ │ └── user.go ├── service │ └── task.go └── statik │ └── statik.go ├── k8s-deploy ├── Dockerfile-agent ├── gocron-agent.yml ├── gocron-st.yml └── mysql-st.yaml ├── makefile ├── package.sh └── web ├── public └── robots.txt └── vue ├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .postcssrc.js ├── README.md ├── build ├── build.js ├── check-versions.js ├── logo.png ├── utils.js ├── vue-loader.conf.js ├── webpack.base.conf.js ├── webpack.dev.conf.js └── webpack.prod.conf.js ├── config ├── dev.env.js ├── index.js └── prod.env.js ├── index.html ├── package.json ├── src ├── App.vue ├── api │ ├── host.js │ ├── install.js │ ├── notification.js │ ├── system.js │ ├── task.js │ ├── taskLog.js │ └── user.js ├── assets │ └── logo.png ├── components │ └── common │ │ ├── footer.vue │ │ ├── header.vue │ │ ├── navMenu.vue │ │ └── notFound.vue ├── main.js ├── pages │ ├── host │ │ ├── edit.vue │ │ └── list.vue │ ├── install │ │ └── index.vue │ ├── system │ │ ├── loginLog.vue │ │ ├── notification │ │ │ ├── email.vue │ │ │ ├── slack.vue │ │ │ ├── tab.vue │ │ │ └── webhook.vue │ │ └── sidebar.vue │ ├── task │ │ ├── edit.vue │ │ ├── list.vue │ │ └── sidebar.vue │ ├── taskLog │ │ └── list.vue │ └── user │ │ ├── edit.vue │ │ ├── editMyPassword.vue │ │ ├── editPassword.vue │ │ ├── list.vue │ │ └── login.vue ├── router │ └── index.js ├── storage │ └── user.js ├── store │ └── index.js └── utils │ └── httpClient.js ├── static └── .gitkeep └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | web/vue/node_modules 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.js linguist-language=go 2 | *.css linguist-language=go 3 | *.html linguist-language=go 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | .DS_Store 27 | .idea 28 | log 29 | data 30 | conf 31 | profile/* 32 | /gocron 33 | /gocron-node 34 | /bin 35 | /web/public/static 36 | /web/public/index.html 37 | /gocron-package 38 | /gocron-node-package 39 | 40 | node_modules 41 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.15-alpine as builder 2 | 3 | RUN apk update \ 4 | && apk add --no-cache git ca-certificates make bash yarn nodejs 5 | 6 | RUN go env -w GO111MODULE=on && \ 7 | go env -w GOPROXY=https://goproxy.cn,direct 8 | 9 | WORKDIR /app 10 | 11 | RUN git clone https://github.com/ouqiang/gocron.git \ 12 | && cd gocron \ 13 | && yarn config set ignore-engines true \ 14 | && make install-vue \ 15 | && make build-vue \ 16 | && make statik \ 17 | && CGO_ENABLED=0 make gocron 18 | 19 | FROM alpine:3.12 20 | 21 | RUN apk add --no-cache ca-certificates tzdata \ 22 | && addgroup -S app \ 23 | && adduser -S -g app app 24 | 25 | RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime 26 | 27 | WORKDIR /app 28 | 29 | COPY --from=builder /app/gocron/bin/gocron . 30 | 31 | RUN chown -R app:app ./ 32 | 33 | EXPOSE 5920 34 | 35 | USER app 36 | 37 | ENTRYPOINT ["/app/gocron", "web"] 38 | -------------------------------------------------------------------------------- /Dockerfile.release: -------------------------------------------------------------------------------- 1 | FROM alpine:3.7 2 | 3 | RUN apk add --no-cache ca-certificates tzdata \ 4 | && addgroup -S app \ 5 | && adduser -S -g app app 6 | 7 | RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime 8 | 9 | WORKDIR /app 10 | 11 | COPY gocron . 12 | 13 | RUN chown -R app:app ./ 14 | 15 | EXPOSE 5920 16 | 17 | USER app 18 | 19 | ENTRYPOINT ["/app/gocron", "web"] 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 qiang.ou 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gocron - 定时任务管理系统 2 | [![Downloads](https://img.shields.io/github/downloads/ouqiang/gocron/total.svg)](https://github.com/ouqiang/gocron/releases) 3 | [![license](https://img.shields.io/github/license/mashape/apistatus.svg?maxAge=2592000)](https://github.com/ouqiang/gocron/blob/master/LICENSE) 4 | [![Release](https://img.shields.io/github/release/ouqiang/gocron.svg?label=Release)](https://github.com/ouqiang/gocron/releases) 5 | 6 | # 项目简介 7 | 使用Go语言开发的轻量级定时任务集中调度和管理系统, 用于替代Linux-crontab [查看文档](https://github.com/ouqiang/gocron/wiki) 8 | 9 | 原有的延时任务拆分为独立项目[延迟队列](https://github.com/ouqiang/delay-queue) 10 | 11 | ## 功能特性 12 | * Web界面管理定时任务 13 | * crontab时间表达式, 精确到秒 14 | * 任务执行失败可重试 15 | * 任务执行超时, 强制结束 16 | * 任务依赖配置, A任务完成后再执行B任务 17 | * 账户权限控制 18 | * 任务类型 19 | * shell任务 20 | > 在任务节点上执行shell命令, 支持任务同时在多个节点上运行 21 | * HTTP任务 22 | > 访问指定的URL地址, 由调度器直接执行, 不依赖任务节点 23 | * 查看任务执行结果日志 24 | * 任务执行结果通知, 支持邮件、Slack、Webhook 25 | 26 | ### 截图 27 | ![流程图](https://raw.githubusercontent.com/ouqiang/gocron/master/assets/screenshot/scheduler.png) 28 | ![任务](https://raw.githubusercontent.com/ouqiang/gocron/master/assets/screenshot/task.png) 29 | ![Slack](https://raw.githubusercontent.com/ouqiang/gocron/master/assets/screenshot/notification.png) 30 | 31 | ### 支持平台 32 | > Windows、Linux、Mac OS 33 | 34 | ### 环境要求 35 | > MySQL 36 | 37 | 38 | ## 下载 39 | [releases](https://github.com/ouqiang/gocron/releases) 40 | 41 | [版本升级](https://github.com/ouqiang/gocron/wiki/版本升级) 42 | 43 | ## 安装 44 | 45 | ### 二进制安装 46 | 1. 解压压缩包   47 | 2. `cd 解压目录` 48 | 3. 启动 49 | * 调度器启动 50 | * Windows: `gocron.exe web` 51 | * Linux、Mac OS: `./gocron web` 52 | * 任务节点启动, 默认监听0.0.0.0:5921 53 | * Windows: `gocron-node.exe` 54 | * Linux、Mac OS: `./gocron-node` 55 | 4. 浏览器访问 http://localhost:5920 56 | 57 | ### 源码安装 58 | 59 | - 安装Go 1.11+ 60 | - `go get -d github.com/ouqiang/gocron` 61 | - `export GO111MODULE=on` 62 | - 编译 `make` 63 | - 启动 64 | * gocron `./bin/gocron web` 65 | * gocron-node `./bin/gocron-node` 66 | 67 | 68 | ### docker 69 | 70 | ```shell 71 | docker run --name gocron --link mysql:db -p 5920:5920 -d ouqg/gocron 72 | ``` 73 | 74 | 配置: /app/conf/app.ini 75 | 76 | 日志: /app/log/cron.log 77 | 78 | 镜像不包含gocron-node, gocron-node需要和具体业务一起构建 79 | 80 | 81 | ### 开发 82 | 83 | 1. 安装Go1.9+, Node.js, Yarn 84 | 2. 安装前端依赖 `make install-vue` 85 | 3. 启动gocron, gocron-node `make run` 86 | 4. 启动node server `make run-vue`, 访问地址 http://localhost:8080 87 | 88 | 访问http://localhost:8080, API请求会转发给gocron 89 | 90 | `make` 编译 91 | 92 | `make run` 编译并运行 93 | 94 | `make package` 打包 95 | > 生成当前系统的压缩包 gocron-v1.5-darwin-amd64.tar.gz gocron-node-v1.5-darwin-amd64.tar.gz 96 | 97 | `make package-all` 生成Windows、Linux、Mac的压缩包 98 | 99 | ### 命令 100 | 101 | * gocron 102 | * -v 查看版本 103 | 104 | * gocron web 105 | * --host 默认0.0.0.0 106 | * -p 端口, 指定端口, 默认5920 107 | * -e 指定运行环境, dev|test|prod, dev模式下可查看更多日志信息, 默认prod 108 | * -h 查看帮助 109 | * gocron-node 110 | * -allow-root *nix平台允许以root用户运行 111 | * -s ip:port 监听地址 112 | * -enable-tls 开启TLS 113 | * -ca-file   CA证书文件   114 | * -cert-file 证书文件 115 | * -key-file 私钥文件 116 | * -h 查看帮助 117 | * -v 查看版本 118 | 119 | ## To Do List 120 | - [x] 版本升级 121 | - [x] 批量开启、关闭、删除任务 122 | - [x] 调度器与任务节点通信支持https 123 | - [x] 任务分组 124 | - [x] 多用户 125 | - [x] 权限控制 126 | 127 | ## 程序使用的组件 128 | * Web框架 [Macaron](http://go-macaron.com/) 129 | * 定时任务调度 [Cron](https://github.com/robfig/cron) 130 | * ORM [Xorm](https://github.com/go-xorm/xorm) 131 | * UI框架 [Element UI](https://github.com/ElemeFE/element) 132 | * 依赖管理 [Govendor](https://github.com/kardianos/govendor) 133 | * RPC框架 [gRPC](https://github.com/grpc/grpc) 134 | 135 | ## 反馈 136 | 提交[issue](https://github.com/ouqiang/gocron/issues/new) 137 | 138 | ## ChangeLog 139 | 140 | v1.5 141 | -------- 142 | * 前端使用Vue+ElementUI重构 143 | * 任务通知 144 | * 新增WebHook通知 145 | * 自定义通知模板 146 | * 匹配任务执行结果关键字发送通知 147 | * 任务列表页显示任务下次执行时间 148 | 149 | v1.4 150 | -------- 151 | * HTTP任务支持POST请求 152 | * 后台手动停止运行中的shell任务 153 | * 任务执行失败重试间隔时间支持用户自定义 154 | * 修复API接口调用报403错误 155 | 156 | v1.3 157 | -------- 158 | * 支持多用户登录 159 | * 增加用户权限控制 160 | 161 | 162 | v1.2.2 163 | -------- 164 | * 用户登录页增加图形验证码 165 | * 支持从旧版本升级 166 | * 任务批量开启、关闭、删除 167 | * 调度器与任务节点支持HTTPS双向认证 168 | * 修复任务列表页总记录数显示错误 169 | 170 | v1.1 171 | -------- 172 | 173 | * 任务可同时在多个节点上运行 174 | * *nix平台默认禁止以root用户运行任务节点 175 | * 子任务命令中增加预定义占位符, 子任务可根据主任务运行结果执行相应操作 176 | * 删除守护进程模块 177 | * Web访问日志输出到终端 178 | -------------------------------------------------------------------------------- /assets/screenshot/notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ouqiang/gocron/960fc988b11ab32a121e4448f600c5ea089962b4/assets/screenshot/notification.png -------------------------------------------------------------------------------- /assets/screenshot/scheduler.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ouqiang/gocron/960fc988b11ab32a121e4448f600c5ea089962b4/assets/screenshot/scheduler.png -------------------------------------------------------------------------------- /assets/screenshot/task.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ouqiang/gocron/960fc988b11ab32a121e4448f600c5ea089962b4/assets/screenshot/task.png -------------------------------------------------------------------------------- /cmd/gocron/gocron.go: -------------------------------------------------------------------------------- 1 | // Command gocron 2 | //go:generate statik -src=../../web/public -dest=../../internal -f 3 | 4 | package main 5 | 6 | import ( 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | 11 | macaron "gopkg.in/macaron.v1" 12 | 13 | "github.com/ouqiang/gocron/internal/models" 14 | "github.com/ouqiang/gocron/internal/modules/app" 15 | "github.com/ouqiang/gocron/internal/modules/logger" 16 | "github.com/ouqiang/gocron/internal/modules/setting" 17 | "github.com/ouqiang/gocron/internal/routers" 18 | "github.com/ouqiang/gocron/internal/service" 19 | "github.com/ouqiang/goutil" 20 | "github.com/urfave/cli" 21 | ) 22 | 23 | var ( 24 | AppVersion = "1.5" 25 | BuildDate, GitCommit string 26 | ) 27 | 28 | // web服务器默认端口 29 | const DefaultPort = 5920 30 | 31 | func main() { 32 | cliApp := cli.NewApp() 33 | cliApp.Name = "gocron" 34 | cliApp.Usage = "gocron service" 35 | cliApp.Version, _ = goutil.FormatAppVersion(AppVersion, GitCommit, BuildDate) 36 | cliApp.Commands = getCommands() 37 | cliApp.Flags = append(cliApp.Flags, []cli.Flag{}...) 38 | err := cliApp.Run(os.Args) 39 | if err != nil { 40 | logger.Fatal(err) 41 | } 42 | } 43 | 44 | // getCommands 45 | func getCommands() []cli.Command { 46 | command := cli.Command{ 47 | Name: "web", 48 | Usage: "run web server", 49 | Action: runWeb, 50 | Flags: []cli.Flag{ 51 | cli.StringFlag{ 52 | Name: "host", 53 | Value: "0.0.0.0", 54 | Usage: "bind host", 55 | }, 56 | cli.IntFlag{ 57 | Name: "port,p", 58 | Value: DefaultPort, 59 | Usage: "bind port", 60 | }, 61 | cli.StringFlag{ 62 | Name: "env,e", 63 | Value: "prod", 64 | Usage: "runtime environment, dev|test|prod", 65 | }, 66 | }, 67 | } 68 | 69 | return []cli.Command{command} 70 | } 71 | 72 | func runWeb(ctx *cli.Context) { 73 | // 设置运行环境 74 | setEnvironment(ctx) 75 | // 初始化应用 76 | app.InitEnv(AppVersion) 77 | // 初始化模块 DB、定时任务等 78 | initModule() 79 | // 捕捉信号,配置热更新等 80 | go catchSignal() 81 | m := macaron.Classic() 82 | // 注册路由 83 | routers.Register(m) 84 | // 注册中间件. 85 | routers.RegisterMiddleware(m) 86 | host := parseHost(ctx) 87 | port := parsePort(ctx) 88 | m.Run(host, port) 89 | } 90 | 91 | func initModule() { 92 | if !app.Installed { 93 | return 94 | } 95 | 96 | config, err := setting.Read(app.AppConfig) 97 | if err != nil { 98 | logger.Fatal("读取应用配置失败", err) 99 | } 100 | app.Setting = config 101 | 102 | // 初始化DB 103 | models.Db = models.CreateDb() 104 | 105 | // 版本升级 106 | upgradeIfNeed() 107 | 108 | // 初始化定时任务 109 | service.ServiceTask.Initialize() 110 | } 111 | 112 | // 解析端口 113 | func parsePort(ctx *cli.Context) int { 114 | port := DefaultPort 115 | if ctx.IsSet("port") { 116 | port = ctx.Int("port") 117 | } 118 | if port <= 0 || port >= 65535 { 119 | port = DefaultPort 120 | } 121 | 122 | return port 123 | } 124 | 125 | func parseHost(ctx *cli.Context) string { 126 | if ctx.IsSet("host") { 127 | return ctx.String("host") 128 | } 129 | 130 | return "0.0.0.0" 131 | } 132 | 133 | func setEnvironment(ctx *cli.Context) { 134 | env := "prod" 135 | if ctx.IsSet("env") { 136 | env = ctx.String("env") 137 | } 138 | 139 | switch env { 140 | case "test": 141 | macaron.Env = macaron.TEST 142 | case "dev": 143 | macaron.Env = macaron.DEV 144 | default: 145 | macaron.Env = macaron.PROD 146 | } 147 | } 148 | 149 | // 捕捉信号 150 | func catchSignal() { 151 | c := make(chan os.Signal, 1) 152 | signal.Notify(c, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM) 153 | for { 154 | s := <-c 155 | logger.Info("收到信号 -- ", s) 156 | switch s { 157 | case syscall.SIGHUP: 158 | logger.Info("收到终端断开信号, 忽略") 159 | case syscall.SIGINT, syscall.SIGTERM: 160 | shutdown() 161 | } 162 | } 163 | } 164 | 165 | // 应用退出 166 | func shutdown() { 167 | defer func() { 168 | logger.Info("已退出") 169 | os.Exit(0) 170 | }() 171 | 172 | if !app.Installed { 173 | return 174 | } 175 | logger.Info("应用准备退出") 176 | // 停止所有任务调度 177 | logger.Info("停止定时任务调度") 178 | service.ServiceTask.WaitAndExit() 179 | } 180 | 181 | // 判断应用是否需要升级, 当存在版本号文件且版本小于app.VersionId时升级 182 | func upgradeIfNeed() { 183 | currentVersionId := app.GetCurrentVersionId() 184 | // 没有版本号文件 185 | if currentVersionId == 0 { 186 | return 187 | } 188 | if currentVersionId >= app.VersionId { 189 | return 190 | } 191 | 192 | migration := new(models.Migration) 193 | logger.Infof("版本升级开始, 当前版本号%d", currentVersionId) 194 | 195 | migration.Upgrade(currentVersionId) 196 | app.UpdateVersionFile() 197 | 198 | logger.Infof("已升级到最新版本%d", app.VersionId) 199 | } 200 | -------------------------------------------------------------------------------- /cmd/node/node.go: -------------------------------------------------------------------------------- 1 | // Command gocron-node 2 | package main 3 | 4 | import ( 5 | "flag" 6 | "os" 7 | "runtime" 8 | "strings" 9 | 10 | "github.com/ouqiang/gocron/internal/modules/rpc/auth" 11 | "github.com/ouqiang/gocron/internal/modules/rpc/server" 12 | "github.com/ouqiang/gocron/internal/modules/utils" 13 | "github.com/ouqiang/goutil" 14 | log "github.com/sirupsen/logrus" 15 | ) 16 | 17 | var ( 18 | AppVersion, BuildDate, GitCommit string 19 | ) 20 | 21 | func main() { 22 | var serverAddr string 23 | var allowRoot bool 24 | var version bool 25 | var CAFile string 26 | var certFile string 27 | var keyFile string 28 | var enableTLS bool 29 | var logLevel string 30 | flag.BoolVar(&allowRoot, "allow-root", false, "./gocron-node -allow-root") 31 | flag.StringVar(&serverAddr, "s", "0.0.0.0:5921", "./gocron-node -s ip:port") 32 | flag.BoolVar(&version, "v", false, "./gocron-node -v") 33 | flag.BoolVar(&enableTLS, "enable-tls", false, "./gocron-node -enable-tls") 34 | flag.StringVar(&CAFile, "ca-file", "", "./gocron-node -ca-file path") 35 | flag.StringVar(&certFile, "cert-file", "", "./gocron-node -cert-file path") 36 | flag.StringVar(&keyFile, "key-file", "", "./gocron-node -key-file path") 37 | flag.StringVar(&logLevel, "log-level", "info", "-log-level error") 38 | flag.Parse() 39 | level, err := log.ParseLevel(logLevel) 40 | if err != nil { 41 | log.Fatal(err) 42 | } 43 | log.SetLevel(level) 44 | 45 | if version { 46 | goutil.PrintAppVersion(AppVersion, GitCommit, BuildDate) 47 | return 48 | } 49 | 50 | if enableTLS { 51 | if !utils.FileExist(CAFile) { 52 | log.Fatalf("failed to read ca cert file: %s", CAFile) 53 | } 54 | if !utils.FileExist(certFile) { 55 | log.Fatalf("failed to read server cert file: %s", certFile) 56 | return 57 | } 58 | if !utils.FileExist(keyFile) { 59 | log.Fatalf("failed to read server key file: %s", keyFile) 60 | return 61 | } 62 | } 63 | 64 | certificate := auth.Certificate{ 65 | CAFile: strings.TrimSpace(CAFile), 66 | CertFile: strings.TrimSpace(certFile), 67 | KeyFile: strings.TrimSpace(keyFile), 68 | } 69 | 70 | if runtime.GOOS != "windows" && os.Getuid() == 0 && !allowRoot { 71 | log.Fatal("Do not run gocron-node as root user") 72 | return 73 | } 74 | 75 | server.Start(serverAddr, enableTLS, certificate) 76 | } 77 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ouqiang/gocron 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/Tang-RoseChild/mahonia v0.0.0-20131226213531-0eef680515cc 7 | github.com/Unknwon/com v0.0.0-20190321035513-0fed4efef755 // indirect 8 | github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 9 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 10 | github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df 11 | github.com/go-macaron/binding v0.0.0-20170611065819-ac54ee249c27 12 | github.com/go-macaron/gzip v0.0.0-20160222043647-cad1c6580a07 13 | github.com/go-macaron/inject v0.0.0-20160627170012-d8a0b8677191 // indirect 14 | github.com/go-macaron/toolbox v0.0.0-20180818072302-a77f45a7ce90 15 | github.com/go-sql-driver/mysql v1.4.1 16 | github.com/go-xorm/builder v0.3.4 // indirect 17 | github.com/go-xorm/core v0.6.2 18 | github.com/go-xorm/xorm v0.7.1 19 | github.com/golang/protobuf v1.3.1 20 | github.com/jakecoffman/cron v0.0.0-20190106200828-7e2009c226a5 21 | github.com/klauspost/compress v1.5.0 // indirect 22 | github.com/klauspost/cpuid v1.2.1 // indirect 23 | github.com/lib/pq v1.1.1 24 | github.com/ouqiang/goutil v1.1.1 25 | github.com/rakyll/statik v0.1.6 26 | github.com/sirupsen/logrus v1.4.2 27 | github.com/urfave/cli v1.20.0 28 | golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5 // indirect 29 | golang.org/x/net v0.0.0-20190522155817-f3200d17e092 30 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f // indirect 31 | golang.org/x/text v0.3.2 // indirect 32 | google.golang.org/genproto v0.0.0-20190530194941-fb225487d101 // indirect 33 | google.golang.org/grpc v1.21.0 34 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect 35 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect 36 | gopkg.in/ini.v1 v1.42.0 37 | gopkg.in/macaron.v1 v1.3.2 38 | ) 39 | -------------------------------------------------------------------------------- /internal/models/host.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/go-xorm/xorm" 5 | ) 6 | 7 | // 主机 8 | type Host struct { 9 | Id int16 `json:"id" xorm:"smallint pk autoincr"` 10 | Name string `json:"name" xorm:"varchar(64) notnull"` // 主机名称 11 | Alias string `json:"alias" xorm:"varchar(32) notnull default '' "` // 主机别名 12 | Port int `json:"port" xorm:"notnull default 5921"` // 主机端口 13 | Remark string `json:"remark" xorm:"varchar(100) notnull default '' "` // 备注 14 | BaseModel `json:"-" xorm:"-"` 15 | Selected bool `json:"-" xorm:"-"` 16 | } 17 | 18 | // 新增 19 | func (host *Host) Create() (insertId int16, err error) { 20 | _, err = Db.Insert(host) 21 | if err == nil { 22 | insertId = host.Id 23 | } 24 | 25 | return 26 | } 27 | 28 | func (host *Host) UpdateBean(id int16) (int64, error) { 29 | return Db.ID(id).Cols("name,alias,port,remark").Update(host) 30 | } 31 | 32 | // 更新 33 | func (host *Host) Update(id int, data CommonMap) (int64, error) { 34 | return Db.Table(host).ID(id).Update(data) 35 | } 36 | 37 | // 删除 38 | func (host *Host) Delete(id int) (int64, error) { 39 | return Db.Id(id).Delete(new(Host)) 40 | } 41 | 42 | func (host *Host) Find(id int) error { 43 | _, err := Db.Id(id).Get(host) 44 | 45 | return err 46 | } 47 | 48 | func (host *Host) NameExists(name string, id int16) (bool, error) { 49 | if id == 0 { 50 | count, err := Db.Where("name = ?", name).Count(host) 51 | return count > 0, err 52 | } 53 | 54 | count, err := Db.Where("name = ? AND id != ?", name, id).Count(host) 55 | return count > 0, err 56 | } 57 | 58 | func (host *Host) List(params CommonMap) ([]Host, error) { 59 | host.parsePageAndPageSize(params) 60 | list := make([]Host, 0) 61 | session := Db.Desc("id") 62 | host.parseWhere(session, params) 63 | err := session.Limit(host.PageSize, host.pageLimitOffset()).Find(&list) 64 | 65 | return list, err 66 | } 67 | 68 | func (host *Host) AllList() ([]Host, error) { 69 | list := make([]Host, 0) 70 | err := Db.Cols("name,port").Desc("id").Find(&list) 71 | 72 | return list, err 73 | } 74 | 75 | func (host *Host) Total(params CommonMap) (int64, error) { 76 | session := Db.NewSession() 77 | host.parseWhere(session, params) 78 | return session.Count(host) 79 | } 80 | 81 | // 解析where 82 | func (host *Host) parseWhere(session *xorm.Session, params CommonMap) { 83 | if len(params) == 0 { 84 | return 85 | } 86 | id, ok := params["Id"] 87 | if ok && id.(int) > 0 { 88 | session.And("id = ?", id) 89 | } 90 | name, ok := params["Name"] 91 | if ok && name.(string) != "" { 92 | session.And("name = ?", name) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /internal/models/login_log.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // 用户登录日志 8 | 9 | type LoginLog struct { 10 | Id int `json:"id" xorm:"pk autoincr notnull "` 11 | Username string `json:"username" xorm:"varchar(32) notnull"` 12 | Ip string `json:"ip" xorm:"varchar(15) not null"` 13 | Created time.Time `json:"created" xorm:"datetime notnull created"` 14 | BaseModel `json:"-" xorm:"-"` 15 | } 16 | 17 | func (log *LoginLog) Create() (insertId int, err error) { 18 | _, err = Db.Insert(log) 19 | if err == nil { 20 | insertId = log.Id 21 | } 22 | 23 | return 24 | } 25 | 26 | func (log *LoginLog) List(params CommonMap) ([]LoginLog, error) { 27 | log.parsePageAndPageSize(params) 28 | list := make([]LoginLog, 0) 29 | err := Db.Desc("id").Limit(log.PageSize, log.pageLimitOffset()).Find(&list) 30 | 31 | return list, err 32 | } 33 | 34 | func (log *LoginLog) Total() (int64, error) { 35 | return Db.Count(log) 36 | } 37 | -------------------------------------------------------------------------------- /internal/models/migration.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | 8 | "github.com/go-xorm/xorm" 9 | "github.com/ouqiang/gocron/internal/modules/logger" 10 | ) 11 | 12 | type Migration struct{} 13 | 14 | // 首次安装, 创建数据库表 15 | func (migration *Migration) Install(dbName string) error { 16 | setting := new(Setting) 17 | task := new(Task) 18 | tables := []interface{}{ 19 | &User{}, task, &TaskLog{}, &Host{}, setting, &LoginLog{}, &TaskHost{}, 20 | } 21 | for _, table := range tables { 22 | exist, err := Db.IsTableExist(table) 23 | if exist { 24 | return errors.New("数据表已存在") 25 | } 26 | if err != nil { 27 | return err 28 | } 29 | err = Db.Sync2(table) 30 | if err != nil { 31 | return err 32 | } 33 | } 34 | setting.InitBasicField() 35 | 36 | return nil 37 | } 38 | 39 | // 迭代升级数据库, 新建表、新增字段等 40 | func (migration *Migration) Upgrade(oldVersionId int) { 41 | // v1.2版本不支持升级 42 | if oldVersionId == 120 { 43 | return 44 | } 45 | 46 | versionIds := []int{110, 122, 130, 140, 150} 47 | upgradeFuncs := []func(*xorm.Session) error{ 48 | migration.upgradeFor110, 49 | migration.upgradeFor122, 50 | migration.upgradeFor130, 51 | migration.upgradeFor140, 52 | migration.upgradeFor150, 53 | } 54 | 55 | startIndex := -1 56 | // 从当前版本的下一版本开始升级 57 | for i, value := range versionIds { 58 | if value > oldVersionId { 59 | startIndex = i 60 | break 61 | } 62 | } 63 | 64 | if startIndex == -1 { 65 | return 66 | } 67 | 68 | length := len(versionIds) 69 | if startIndex >= length { 70 | return 71 | } 72 | 73 | session := Db.NewSession() 74 | err := session.Begin() 75 | if err != nil { 76 | logger.Fatalf("开启事务失败-%s", err.Error()) 77 | } 78 | for startIndex < length { 79 | err = upgradeFuncs[startIndex](session) 80 | if err == nil { 81 | startIndex++ 82 | continue 83 | } 84 | dbErr := session.Rollback() 85 | if dbErr != nil { 86 | logger.Fatalf("事务回滚失败-%s", dbErr.Error()) 87 | } 88 | logger.Fatal(err) 89 | } 90 | err = session.Commit() 91 | if err != nil { 92 | logger.Fatalf("提交事务失败-%s", err.Error()) 93 | } 94 | } 95 | 96 | // 升级到v1.1版本 97 | func (migration *Migration) upgradeFor110(session *xorm.Session) error { 98 | logger.Info("开始升级到v1.1") 99 | // 创建表task_host 100 | err := session.Sync2(new(TaskHost)) 101 | if err != nil { 102 | return err 103 | } 104 | 105 | tableName := TablePrefix + "task" 106 | // 把task对应的host_id写入task_host表 107 | sql := fmt.Sprintf("SELECT id, host_id FROM %s WHERE host_id > 0", tableName) 108 | results, err := session.Query(sql) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | for _, value := range results { 114 | taskHostModel := &TaskHost{} 115 | taskId, err := strconv.Atoi(string(value["id"])) 116 | if err != nil { 117 | return err 118 | } 119 | hostId, err := strconv.Atoi(string(value["host_id"])) 120 | if err != nil { 121 | return err 122 | } 123 | taskHostModel.TaskId = taskId 124 | taskHostModel.HostId = int16(hostId) 125 | _, err = session.Insert(taskHostModel) 126 | if err != nil { 127 | return err 128 | } 129 | } 130 | 131 | // 删除task表host_id字段 132 | _, err = session.Exec(fmt.Sprintf("ALTER TABLE %s DROP COLUMN host_id", tableName)) 133 | 134 | logger.Info("已升级到v1.1\n") 135 | 136 | return err 137 | } 138 | 139 | // 升级到1.2.2版本 140 | func (migration *Migration) upgradeFor122(session *xorm.Session) error { 141 | logger.Info("开始升级到v1.2.2") 142 | 143 | tableName := TablePrefix + "task" 144 | // task表增加tag字段 145 | _, err := session.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN tag VARCHAR(32) NOT NULL DEFAULT '' ", tableName)) 146 | 147 | logger.Info("已升级到v1.2.2\n") 148 | 149 | return err 150 | } 151 | 152 | // 升级到v1.3版本 153 | func (migration *Migration) upgradeFor130(session *xorm.Session) error { 154 | logger.Info("开始升级到v1.3") 155 | 156 | tableName := TablePrefix + "user" 157 | // 删除user表deleted字段 158 | _, err := session.Exec(fmt.Sprintf("ALTER TABLE %s DROP COLUMN deleted", tableName)) 159 | 160 | logger.Info("已升级到v1.3\n") 161 | 162 | return err 163 | } 164 | 165 | // 升级到v1.4版本 166 | func (migration *Migration) upgradeFor140(session *xorm.Session) error { 167 | logger.Info("开始升级到v1.4") 168 | 169 | tableName := TablePrefix + "task" 170 | // task表增加字段 171 | // retry_interval 重试间隔时间(秒) 172 | // http_method http请求方法 173 | sql := fmt.Sprintf( 174 | "ALTER TABLE %s ADD COLUMN retry_interval SMALLINT NOT NULL DEFAULT 0,ADD COLUMN http_method TINYINT NOT NULL DEFAULT 1", tableName) 175 | _, err := session.Exec(sql) 176 | 177 | if err != nil { 178 | return err 179 | } 180 | 181 | logger.Info("已升级到v1.4\n") 182 | 183 | return err 184 | } 185 | 186 | func (m *Migration) upgradeFor150(session *xorm.Session) error { 187 | logger.Info("开始升级到v1.5") 188 | 189 | tableName := TablePrefix + "task" 190 | // task表增加字段 notify_keyword 191 | sql := fmt.Sprintf( 192 | "ALTER TABLE %s ADD COLUMN notify_keyword VARCHAR(128) NOT NULL DEFAULT '' ", tableName) 193 | _, err := session.Exec(sql) 194 | 195 | if err != nil { 196 | return err 197 | } 198 | 199 | settingModel := new(Setting) 200 | settingModel.Code = MailCode 201 | settingModel.Key = MailTemplateKey 202 | settingModel.Value = emailTemplate 203 | _, err = Db.Insert(settingModel) 204 | if err != nil { 205 | return err 206 | } 207 | settingModel.Id = 0 208 | settingModel.Code = SlackCode 209 | settingModel.Key = SlackTemplateKey 210 | settingModel.Value = slackTemplate 211 | _, err = Db.Insert(settingModel) 212 | if err != nil { 213 | return err 214 | } 215 | 216 | settingModel.Id = 0 217 | settingModel.Code = WebhookCode 218 | settingModel.Key = WebhookUrlKey 219 | settingModel.Value = "" 220 | _, err = Db.Insert(settingModel) 221 | if err != nil { 222 | return err 223 | } 224 | 225 | settingModel.Id = 0 226 | settingModel.Code = WebhookCode 227 | settingModel.Key = WebhookTemplateKey 228 | settingModel.Value = webhookTemplate 229 | _, err = Db.Insert(settingModel) 230 | if err != nil { 231 | return err 232 | } 233 | 234 | logger.Info("已升级到v1.5\n") 235 | 236 | return nil 237 | } 238 | -------------------------------------------------------------------------------- /internal/models/model.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | macaron "gopkg.in/macaron.v1" 9 | 10 | _ "github.com/go-sql-driver/mysql" 11 | "github.com/go-xorm/core" 12 | "github.com/go-xorm/xorm" 13 | _ "github.com/lib/pq" 14 | "github.com/ouqiang/gocron/internal/modules/app" 15 | "github.com/ouqiang/gocron/internal/modules/logger" 16 | "github.com/ouqiang/gocron/internal/modules/setting" 17 | ) 18 | 19 | type Status int8 20 | type CommonMap map[string]interface{} 21 | 22 | var TablePrefix = "" 23 | var Db *xorm.Engine 24 | 25 | const ( 26 | Disabled Status = 0 // 禁用 27 | Failure Status = 0 // 失败 28 | Enabled Status = 1 // 启用 29 | Running Status = 1 // 运行中 30 | Finish Status = 2 // 完成 31 | Cancel Status = 3 // 取消 32 | ) 33 | 34 | const ( 35 | Page = 1 // 当前页数 36 | PageSize = 20 // 每页多少条数据 37 | MaxPageSize = 1000 // 每次最多取多少条 38 | ) 39 | 40 | const DefaultTimeFormat = "2006-01-02 15:04:05" 41 | 42 | const ( 43 | dbPingInterval = 90 * time.Second 44 | dbMaxLiftTime = 2 * time.Hour 45 | ) 46 | 47 | type BaseModel struct { 48 | Page int `xorm:"-"` 49 | PageSize int `xorm:"-"` 50 | } 51 | 52 | func (model *BaseModel) parsePageAndPageSize(params CommonMap) { 53 | page, ok := params["Page"] 54 | if ok { 55 | model.Page = page.(int) 56 | } 57 | pageSize, ok := params["PageSize"] 58 | if ok { 59 | model.PageSize = pageSize.(int) 60 | } 61 | if model.Page <= 0 { 62 | model.Page = Page 63 | } 64 | if model.PageSize <= 0 { 65 | model.PageSize = MaxPageSize 66 | } 67 | } 68 | 69 | func (model *BaseModel) pageLimitOffset() int { 70 | return (model.Page - 1) * model.PageSize 71 | } 72 | 73 | // 创建Db 74 | func CreateDb() *xorm.Engine { 75 | dsn := getDbEngineDSN(app.Setting) 76 | engine, err := xorm.NewEngine(app.Setting.Db.Engine, dsn) 77 | if err != nil { 78 | logger.Fatal("创建xorm引擎失败", err) 79 | } 80 | engine.SetMaxIdleConns(app.Setting.Db.MaxIdleConns) 81 | engine.SetMaxOpenConns(app.Setting.Db.MaxOpenConns) 82 | engine.SetConnMaxLifetime(dbMaxLiftTime) 83 | 84 | if app.Setting.Db.Prefix != "" { 85 | // 设置表前缀 86 | TablePrefix = app.Setting.Db.Prefix 87 | mapper := core.NewPrefixMapper(core.SnakeMapper{}, app.Setting.Db.Prefix) 88 | engine.SetTableMapper(mapper) 89 | } 90 | // 本地环境开启日志 91 | if macaron.Env == macaron.DEV { 92 | engine.ShowSQL(true) 93 | engine.Logger().SetLevel(core.LOG_DEBUG) 94 | } 95 | 96 | go keepDbAlived(engine) 97 | 98 | return engine 99 | } 100 | 101 | // 创建临时数据库连接 102 | func CreateTmpDb(setting *setting.Setting) (*xorm.Engine, error) { 103 | dsn := getDbEngineDSN(setting) 104 | 105 | return xorm.NewEngine(setting.Db.Engine, dsn) 106 | } 107 | 108 | // 获取数据库引擎DSN mysql,sqlite,postgres 109 | func getDbEngineDSN(setting *setting.Setting) string { 110 | engine := strings.ToLower(setting.Db.Engine) 111 | dsn := "" 112 | switch engine { 113 | case "mysql": 114 | dsn = fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&allowNativePasswords=true", 115 | setting.Db.User, 116 | setting.Db.Password, 117 | setting.Db.Host, 118 | setting.Db.Port, 119 | setting.Db.Database, 120 | setting.Db.Charset) 121 | case "postgres": 122 | dsn = fmt.Sprintf("user=%s password=%s host=%s port=%d dbname=%s sslmode=disable", 123 | setting.Db.User, 124 | setting.Db.Password, 125 | setting.Db.Host, 126 | setting.Db.Port, 127 | setting.Db.Database) 128 | } 129 | 130 | return dsn 131 | } 132 | 133 | func keepDbAlived(engine *xorm.Engine) { 134 | t := time.Tick(dbPingInterval) 135 | var err error 136 | for { 137 | <-t 138 | err = engine.Ping() 139 | if err != nil { 140 | logger.Infof("database ping: %s", err) 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /internal/models/setting.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | type Setting struct { 8 | Id int `xorm:"int pk autoincr"` 9 | Code string `xorm:"varchar(32) notnull"` 10 | Key string `xorm:"varchar(64) notnull"` 11 | Value string `xorm:"varchar(4096) notnull default '' "` 12 | } 13 | 14 | const slackTemplate = ` 15 | 任务ID: {{.TaskId}} 16 | 任务名称: {{.TaskName}} 17 | 状态: {{.Status}} 18 | 执行结果: {{.Result}} 19 | 备注: {{.Remark}} 20 | ` 21 | const emailTemplate = ` 22 | 任务ID: {{.TaskId}} 23 | 任务名称: {{.TaskName}} 24 | 状态: {{.Status}} 25 | 执行结果: {{.Result}} 26 | 备注: {{.Remark}} 27 | ` 28 | const webhookTemplate = ` 29 | { 30 | "task_id": "{{.TaskId}}", 31 | "task_name": "{{.TaskName}}", 32 | "status": "{{.Status}}", 33 | "result": "{{.Result}}", 34 | "remark": "{{.Remark}}" 35 | } 36 | ` 37 | 38 | const ( 39 | SlackCode = "slack" 40 | SlackUrlKey = "url" 41 | SlackTemplateKey = "template" 42 | SlackChannelKey = "channel" 43 | ) 44 | 45 | const ( 46 | MailCode = "mail" 47 | MailTemplateKey = "template" 48 | MailServerKey = "server" 49 | MailUserKey = "user" 50 | ) 51 | 52 | const ( 53 | WebhookCode = "webhook" 54 | WebhookTemplateKey = "template" 55 | WebhookUrlKey = "url" 56 | ) 57 | 58 | // 初始化基本字段 邮件、slack等 59 | func (setting *Setting) InitBasicField() { 60 | setting.Code = SlackCode 61 | setting.Key = SlackUrlKey 62 | setting.Value = "" 63 | Db.Insert(setting) 64 | setting.Id = 0 65 | 66 | setting.Code = SlackCode 67 | setting.Key = SlackTemplateKey 68 | setting.Value = slackTemplate 69 | Db.Insert(setting) 70 | setting.Id = 0 71 | 72 | setting.Code = MailCode 73 | setting.Key = MailServerKey 74 | setting.Value = "" 75 | Db.Insert(setting) 76 | setting.Id = 0 77 | 78 | setting.Code = MailCode 79 | setting.Key = MailTemplateKey 80 | setting.Value = emailTemplate 81 | Db.Insert(setting) 82 | setting.Id = 0 83 | 84 | setting.Code = WebhookCode 85 | setting.Key = WebhookTemplateKey 86 | setting.Value = webhookTemplate 87 | Db.Insert(setting) 88 | setting.Id = 0 89 | 90 | setting.Code = WebhookCode 91 | setting.Key = WebhookUrlKey 92 | setting.Value = "" 93 | Db.Insert(setting) 94 | } 95 | 96 | // region slack配置 97 | 98 | type Slack struct { 99 | Url string `json:"url"` 100 | Channels []Channel `json:"channels"` 101 | Template string `json:"template"` 102 | } 103 | 104 | type Channel struct { 105 | Id int `json:"id"` 106 | Name string `json:"name"` 107 | } 108 | 109 | func (setting *Setting) Slack() (Slack, error) { 110 | list := make([]Setting, 0) 111 | err := Db.Where("code = ?", SlackCode).Find(&list) 112 | slack := Slack{} 113 | if err != nil { 114 | return slack, err 115 | } 116 | 117 | setting.formatSlack(list, &slack) 118 | 119 | return slack, err 120 | } 121 | 122 | func (setting *Setting) formatSlack(list []Setting, slack *Slack) { 123 | for _, v := range list { 124 | switch v.Key { 125 | case SlackUrlKey: 126 | slack.Url = v.Value 127 | case SlackTemplateKey: 128 | slack.Template = v.Value 129 | default: 130 | slack.Channels = append(slack.Channels, Channel{ 131 | v.Id, v.Value, 132 | }) 133 | } 134 | } 135 | } 136 | 137 | func (setting *Setting) UpdateSlack(url, template string) error { 138 | setting.Value = url 139 | 140 | Db.Cols("value").Update(setting, Setting{Code: SlackCode, Key: SlackUrlKey}) 141 | 142 | setting.Value = template 143 | Db.Cols("value").Update(setting, Setting{Code: SlackCode, Key: SlackTemplateKey}) 144 | 145 | return nil 146 | } 147 | 148 | // 创建slack渠道 149 | func (setting *Setting) CreateChannel(channel string) (int64, error) { 150 | setting.Code = SlackCode 151 | setting.Key = SlackChannelKey 152 | setting.Value = channel 153 | 154 | return Db.Insert(setting) 155 | } 156 | 157 | func (setting *Setting) IsChannelExist(channel string) bool { 158 | setting.Code = SlackCode 159 | setting.Key = SlackChannelKey 160 | setting.Value = channel 161 | 162 | count, _ := Db.Count(setting) 163 | 164 | return count > 0 165 | } 166 | 167 | // 删除slack渠道 168 | func (setting *Setting) RemoveChannel(id int) (int64, error) { 169 | setting.Code = SlackCode 170 | setting.Key = SlackChannelKey 171 | setting.Id = id 172 | return Db.Delete(setting) 173 | } 174 | 175 | // endregion 176 | 177 | type Mail struct { 178 | Host string `json:"host"` 179 | Port int `json:"port"` 180 | User string `json:"user"` 181 | Password string `json:"password"` 182 | MailUsers []MailUser `json:"mail_users"` 183 | Template string `json:"template"` 184 | } 185 | 186 | type MailUser struct { 187 | Id int `json:"id"` 188 | Username string `json:"username"` 189 | Email string `json:"email"` 190 | } 191 | 192 | // region 邮件配置 193 | func (setting *Setting) Mail() (Mail, error) { 194 | list := make([]Setting, 0) 195 | err := Db.Where("code = ?", MailCode).Find(&list) 196 | mail := Mail{MailUsers: make([]MailUser, 0)} 197 | if err != nil { 198 | return mail, err 199 | } 200 | 201 | setting.formatMail(list, &mail) 202 | 203 | return mail, err 204 | } 205 | 206 | func (setting *Setting) formatMail(list []Setting, mail *Mail) { 207 | mailUser := MailUser{} 208 | for _, v := range list { 209 | switch v.Key { 210 | case MailServerKey: 211 | json.Unmarshal([]byte(v.Value), mail) 212 | case MailUserKey: 213 | json.Unmarshal([]byte(v.Value), &mailUser) 214 | mailUser.Id = v.Id 215 | mail.MailUsers = append(mail.MailUsers, mailUser) 216 | case MailTemplateKey: 217 | mail.Template = v.Value 218 | } 219 | 220 | } 221 | } 222 | 223 | func (setting *Setting) UpdateMail(config, template string) error { 224 | setting.Value = config 225 | Db.Cols("value").Update(setting, Setting{Code: MailCode, Key: MailServerKey}) 226 | 227 | setting.Value = template 228 | Db.Cols("value").Update(setting, Setting{Code: MailCode, Key: MailTemplateKey}) 229 | 230 | return nil 231 | } 232 | 233 | func (setting *Setting) CreateMailUser(username, email string) (int64, error) { 234 | setting.Code = MailCode 235 | setting.Key = MailUserKey 236 | mailUser := MailUser{0, username, email} 237 | jsonByte, err := json.Marshal(mailUser) 238 | if err != nil { 239 | return 0, err 240 | } 241 | setting.Value = string(jsonByte) 242 | 243 | return Db.Insert(setting) 244 | } 245 | 246 | func (setting *Setting) RemoveMailUser(id int) (int64, error) { 247 | setting.Code = MailCode 248 | setting.Key = MailUserKey 249 | setting.Id = id 250 | return Db.Delete(setting) 251 | } 252 | 253 | type WebHook struct { 254 | Url string `json:"url"` 255 | Template string `json:"template"` 256 | } 257 | 258 | func (setting *Setting) Webhook() (WebHook, error) { 259 | list := make([]Setting, 0) 260 | err := Db.Where("code = ?", WebhookCode).Find(&list) 261 | webHook := WebHook{} 262 | if err != nil { 263 | return webHook, err 264 | } 265 | 266 | setting.formatWebhook(list, &webHook) 267 | 268 | return webHook, err 269 | } 270 | 271 | func (setting *Setting) formatWebhook(list []Setting, webHook *WebHook) { 272 | for _, v := range list { 273 | switch v.Key { 274 | case WebhookUrlKey: 275 | webHook.Url = v.Value 276 | case WebhookTemplateKey: 277 | webHook.Template = v.Value 278 | } 279 | 280 | } 281 | } 282 | 283 | func (setting *Setting) UpdateWebHook(url, template string) error { 284 | setting.Value = url 285 | 286 | Db.Cols("value").Update(setting, Setting{Code: WebhookCode, Key: WebhookUrlKey}) 287 | 288 | setting.Value = template 289 | Db.Cols("value").Update(setting, Setting{Code: WebhookCode, Key: WebhookTemplateKey}) 290 | 291 | return nil 292 | } 293 | 294 | // endregion 295 | -------------------------------------------------------------------------------- /internal/models/task_host.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type TaskHost struct { 4 | Id int `json:"id" xorm:"int pk autoincr"` 5 | TaskId int `json:"task_id" xorm:"int not null index"` 6 | HostId int16 `json:"host_id" xorm:"smallint not null index"` 7 | } 8 | 9 | type TaskHostDetail struct { 10 | TaskHost `xorm:"extends"` 11 | Name string `json:"name"` 12 | Port int `json:"port"` 13 | Alias string `json:"alias"` 14 | } 15 | 16 | func (TaskHostDetail) TableName() string { 17 | return TablePrefix + "task_host" 18 | } 19 | 20 | func hostTableName() []string { 21 | return []string{TablePrefix + "host", "h"} 22 | } 23 | 24 | func (th *TaskHost) Remove(taskId int) error { 25 | _, err := Db.Where("task_id = ?", taskId).Delete(new(TaskHost)) 26 | 27 | return err 28 | } 29 | 30 | func (th *TaskHost) Add(taskId int, hostIds []int) error { 31 | 32 | err := th.Remove(taskId) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | taskHosts := make([]TaskHost, len(hostIds)) 38 | for i, value := range hostIds { 39 | taskHosts[i].TaskId = taskId 40 | taskHosts[i].HostId = int16(value) 41 | } 42 | 43 | _, err = Db.Insert(&taskHosts) 44 | 45 | return err 46 | } 47 | 48 | func (th *TaskHost) GetHostIdsByTaskId(taskId int) ([]TaskHostDetail, error) { 49 | list := make([]TaskHostDetail, 0) 50 | fields := "th.id,th.host_id,h.alias,h.name,h.port" 51 | err := Db.Alias("th"). 52 | Join("LEFT", hostTableName(), "th.host_id=h.id"). 53 | Where("th.task_id = ?", taskId). 54 | Cols(fields). 55 | Find(&list) 56 | 57 | return list, err 58 | } 59 | 60 | func (th *TaskHost) GetTaskIdsByHostId(hostId int16) ([]interface{}, error) { 61 | list := make([]TaskHost, 0) 62 | err := Db.Where("host_id = ?", hostId).Cols("task_id").Find(&list) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | taskIds := make([]interface{}, len(list)) 68 | for i, value := range list { 69 | taskIds[i] = value.TaskId 70 | } 71 | 72 | return taskIds, err 73 | } 74 | 75 | // 判断主机id是否有引用 76 | func (th *TaskHost) HostIdExist(hostId int16) (bool, error) { 77 | count, err := Db.Where("host_id = ?", hostId).Count(th) 78 | 79 | return count > 0, err 80 | } 81 | -------------------------------------------------------------------------------- /internal/models/task_log.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/go-xorm/xorm" 7 | ) 8 | 9 | type TaskType int8 10 | 11 | // 任务执行日志 12 | type TaskLog struct { 13 | Id int64 `json:"id" xorm:"bigint pk autoincr"` 14 | TaskId int `json:"task_id" xorm:"int notnull index default 0"` // 任务id 15 | Name string `json:"name" xorm:"varchar(32) notnull"` // 任务名称 16 | Spec string `json:"spec" xorm:"varchar(64) notnull"` // crontab 17 | Protocol TaskProtocol `json:"protocol" xorm:"tinyint notnull index"` // 协议 1:http 2:RPC 18 | Command string `json:"command" xorm:"varchar(256) notnull"` // URL地址或shell命令 19 | Timeout int `json:"timeout" xorm:"mediumint notnull default 0"` // 任务执行超时时间(单位秒),0不限制 20 | RetryTimes int8 `json:"retry_times" xorm:"tinyint notnull default 0"` // 任务重试次数 21 | Hostname string `json:"hostname" xorm:"varchar(128) notnull default '' "` // RPC主机名,逗号分隔 22 | StartTime time.Time `json:"start_time" xorm:"datetime created"` // 开始执行时间 23 | EndTime time.Time `json:"end_time" xorm:"datetime updated"` // 执行完成(失败)时间 24 | Status Status `json:"status" xorm:"tinyint notnull index default 1"` // 状态 0:执行失败 1:执行中 2:执行完毕 3:任务取消(上次任务未执行完成) 4:异步执行 25 | Result string `json:"result" xorm:"mediumtext notnull "` // 执行结果 26 | TotalTime int `json:"total_time" xorm:"-"` // 执行总时长 27 | BaseModel `json:"-" xorm:"-"` 28 | } 29 | 30 | func (taskLog *TaskLog) Create() (insertId int64, err error) { 31 | _, err = Db.Insert(taskLog) 32 | if err == nil { 33 | insertId = taskLog.Id 34 | } 35 | 36 | return 37 | } 38 | 39 | // 更新 40 | func (taskLog *TaskLog) Update(id int64, data CommonMap) (int64, error) { 41 | return Db.Table(taskLog).ID(id).Update(data) 42 | } 43 | 44 | func (taskLog *TaskLog) List(params CommonMap) ([]TaskLog, error) { 45 | taskLog.parsePageAndPageSize(params) 46 | list := make([]TaskLog, 0) 47 | session := Db.Desc("id") 48 | taskLog.parseWhere(session, params) 49 | err := session.Limit(taskLog.PageSize, taskLog.pageLimitOffset()).Find(&list) 50 | if len(list) > 0 { 51 | for i, item := range list { 52 | endTime := item.EndTime 53 | if item.Status == Running { 54 | endTime = time.Now() 55 | } 56 | execSeconds := endTime.Sub(item.StartTime).Seconds() 57 | list[i].TotalTime = int(execSeconds) 58 | } 59 | } 60 | 61 | return list, err 62 | } 63 | 64 | // 清空表 65 | func (taskLog *TaskLog) Clear() (int64, error) { 66 | return Db.Where("1=1").Delete(taskLog) 67 | } 68 | 69 | // 删除N个月前的日志 70 | func (taskLog *TaskLog) Remove(id int) (int64, error) { 71 | t := time.Now().AddDate(0, -id, 0) 72 | return Db.Where("start_time <= ?", t.Format(DefaultTimeFormat)).Delete(taskLog) 73 | } 74 | 75 | func (taskLog *TaskLog) Total(params CommonMap) (int64, error) { 76 | session := Db.NewSession() 77 | defer session.Close() 78 | taskLog.parseWhere(session, params) 79 | return session.Count(taskLog) 80 | } 81 | 82 | // 解析where 83 | func (taskLog *TaskLog) parseWhere(session *xorm.Session, params CommonMap) { 84 | if len(params) == 0 { 85 | return 86 | } 87 | taskId, ok := params["TaskId"] 88 | if ok && taskId.(int) > 0 { 89 | session.And("task_id = ?", taskId) 90 | } 91 | protocol, ok := params["Protocol"] 92 | if ok && protocol.(int) > 0 { 93 | session.And("protocol = ?", protocol) 94 | } 95 | status, ok := params["Status"] 96 | if ok && status.(int) > -1 { 97 | session.And("status = ?", status) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /internal/models/user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/ouqiang/gocron/internal/modules/utils" 7 | ) 8 | 9 | const PasswordSaltLength = 6 10 | 11 | // 用户model 12 | type User struct { 13 | Id int `json:"id" xorm:"pk autoincr notnull "` 14 | Name string `json:"name" xorm:"varchar(32) notnull unique"` // 用户名 15 | Password string `json:"-" xorm:"char(32) notnull "` // 密码 16 | Salt string `json:"-" xorm:"char(6) notnull "` // 密码盐值 17 | Email string `json:"email" xorm:"varchar(50) notnull unique default '' "` // 邮箱 18 | Created time.Time `json:"created" xorm:"datetime notnull created"` 19 | Updated time.Time `json:"updated" xorm:"datetime updated"` 20 | IsAdmin int8 `json:"is_admin" xorm:"tinyint notnull default 0"` // 是否是管理员 1:管理员 0:普通用户 21 | Status Status `json:"status" xorm:"tinyint notnull default 1"` // 1: 正常 0:禁用 22 | BaseModel `json:"-" xorm:"-"` 23 | } 24 | 25 | // 新增 26 | func (user *User) Create() (insertId int, err error) { 27 | user.Status = Enabled 28 | user.Salt = user.generateSalt() 29 | user.Password = user.encryptPassword(user.Password, user.Salt) 30 | 31 | _, err = Db.Insert(user) 32 | if err == nil { 33 | insertId = user.Id 34 | } 35 | 36 | return 37 | } 38 | 39 | // 更新 40 | func (user *User) Update(id int, data CommonMap) (int64, error) { 41 | return Db.Table(user).ID(id).Update(data) 42 | } 43 | 44 | func (user *User) UpdatePassword(id int, password string) (int64, error) { 45 | salt := user.generateSalt() 46 | safePassword := user.encryptPassword(password, salt) 47 | 48 | return user.Update(id, CommonMap{"password": safePassword, "salt": salt}) 49 | } 50 | 51 | // 删除 52 | func (user *User) Delete(id int) (int64, error) { 53 | return Db.Id(id).Delete(user) 54 | } 55 | 56 | // 禁用 57 | func (user *User) Disable(id int) (int64, error) { 58 | return user.Update(id, CommonMap{"status": Disabled}) 59 | } 60 | 61 | // 激活 62 | func (user *User) Enable(id int) (int64, error) { 63 | return user.Update(id, CommonMap{"status": Enabled}) 64 | } 65 | 66 | // 验证用户名和密码 67 | func (user *User) Match(username, password string) bool { 68 | where := "(name = ? OR email = ?) AND status =? " 69 | _, err := Db.Where(where, username, username, Enabled).Get(user) 70 | if err != nil { 71 | return false 72 | } 73 | hashPassword := user.encryptPassword(password, user.Salt) 74 | 75 | return hashPassword == user.Password 76 | } 77 | 78 | // 获取用户详情 79 | func (user *User) Find(id int) error { 80 | _, err := Db.Id(id).Get(user) 81 | 82 | return err 83 | } 84 | 85 | // 用户名是否存在 86 | func (user *User) UsernameExists(username string, uid int) (int64, error) { 87 | if uid > 0 { 88 | return Db.Where("name = ? AND id != ?", username, uid).Count(user) 89 | } 90 | 91 | return Db.Where("name = ?", username).Count(user) 92 | } 93 | 94 | // 邮箱地址是否存在 95 | func (user *User) EmailExists(email string, uid int) (int64, error) { 96 | if uid > 0 { 97 | return Db.Where("email = ? AND id != ?", email, uid).Count(user) 98 | } 99 | 100 | return Db.Where("email = ?", email).Count(user) 101 | } 102 | 103 | func (user *User) List(params CommonMap) ([]User, error) { 104 | user.parsePageAndPageSize(params) 105 | list := make([]User, 0) 106 | err := Db.Desc("id").Limit(user.PageSize, user.pageLimitOffset()).Find(&list) 107 | 108 | return list, err 109 | } 110 | 111 | func (user *User) Total() (int64, error) { 112 | return Db.Count(user) 113 | } 114 | 115 | // 密码加密 116 | func (user *User) encryptPassword(password, salt string) string { 117 | return utils.Md5(password + salt) 118 | } 119 | 120 | // 生成密码盐值 121 | func (user *User) generateSalt() string { 122 | return utils.RandString(PasswordSaltLength) 123 | } 124 | -------------------------------------------------------------------------------- /internal/modules/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "fmt" 8 | "io/ioutil" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/ouqiang/gocron/internal/modules/logger" 13 | "github.com/ouqiang/gocron/internal/modules/setting" 14 | "github.com/ouqiang/gocron/internal/modules/utils" 15 | "github.com/ouqiang/goutil" 16 | ) 17 | 18 | var ( 19 | // AppDir 应用根目录 20 | AppDir string // 应用根目录 21 | // ConfDir 配置文件目录 22 | ConfDir string // 配置目录 23 | // LogDir 日志目录 24 | LogDir string // 日志目录 25 | // AppConfig 配置文件 26 | AppConfig string // 应用配置文件 27 | // Installed 应用是否已安装 28 | Installed bool // 应用是否安装过 29 | // Setting 应用配置 30 | Setting *setting.Setting // 应用配置 31 | // VersionId 版本号 32 | VersionId int // 版本号 33 | // VersionFile 版本文件 34 | VersionFile string // 版本号文件 35 | ) 36 | 37 | // InitEnv 初始化 38 | func InitEnv(versionString string) { 39 | logger.InitLogger() 40 | var err error 41 | AppDir, err = goutil.WorkDir() 42 | if err != nil { 43 | logger.Fatal(err) 44 | } 45 | ConfDir = filepath.Join(AppDir, "/conf") 46 | LogDir = filepath.Join(AppDir, "/log") 47 | AppConfig = filepath.Join(ConfDir, "/app.ini") 48 | VersionFile = filepath.Join(ConfDir, "/.version") 49 | createDirIfNotExists(ConfDir, LogDir) 50 | Installed = IsInstalled() 51 | VersionId = ToNumberVersion(versionString) 52 | } 53 | 54 | // IsInstalled 判断应用是否已安装 55 | func IsInstalled() bool { 56 | _, err := os.Stat(filepath.Join(ConfDir, "/install.lock")) 57 | if os.IsNotExist(err) { 58 | return false 59 | } 60 | 61 | return true 62 | } 63 | 64 | // CreateInstallLock 创建安装锁文件 65 | func CreateInstallLock() error { 66 | _, err := os.Create(filepath.Join(ConfDir, "/install.lock")) 67 | if err != nil { 68 | logger.Error("创建安装锁文件conf/install.lock失败") 69 | } 70 | 71 | return err 72 | } 73 | 74 | // UpdateVersionFile 更新应用版本号文件 75 | func UpdateVersionFile() { 76 | err := ioutil.WriteFile(VersionFile, 77 | []byte(strconv.Itoa(VersionId)), 78 | 0644, 79 | ) 80 | 81 | if err != nil { 82 | logger.Fatal(err) 83 | } 84 | } 85 | 86 | // GetCurrentVersionId 获取应用当前版本号, 从版本号文件中读取 87 | func GetCurrentVersionId() int { 88 | if !utils.FileExist(VersionFile) { 89 | return 0 90 | } 91 | 92 | bytes, err := ioutil.ReadFile(VersionFile) 93 | if err != nil { 94 | logger.Fatal(err) 95 | } 96 | 97 | versionId, err := strconv.Atoi(strings.TrimSpace(string(bytes))) 98 | if err != nil { 99 | logger.Fatal(err) 100 | } 101 | 102 | return versionId 103 | } 104 | 105 | // ToNumberVersion 把字符串版本号a.b.c转换为整数版本号abc 106 | func ToNumberVersion(versionString string) int { 107 | versionString = strings.TrimPrefix(versionString, "v") 108 | v := strings.Replace(versionString, ".", "", -1) 109 | if len(v) < 3 { 110 | v += "0" 111 | } 112 | 113 | versionId, err := strconv.Atoi(v) 114 | if err != nil { 115 | logger.Fatal(err) 116 | } 117 | 118 | return versionId 119 | } 120 | 121 | // 检测目录是否存在 122 | func createDirIfNotExists(path ...string) { 123 | for _, value := range path { 124 | if utils.FileExist(value) { 125 | continue 126 | } 127 | err := os.Mkdir(value, 0755) 128 | if err != nil { 129 | logger.Fatal(fmt.Sprintf("创建目录失败:%s", err.Error())) 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /internal/modules/httpclient/http_client.go: -------------------------------------------------------------------------------- 1 | package httpclient 2 | 3 | // http-client 4 | 5 | import ( 6 | "bytes" 7 | "fmt" 8 | "io/ioutil" 9 | "net/http" 10 | "time" 11 | ) 12 | 13 | type ResponseWrapper struct { 14 | StatusCode int 15 | Body string 16 | Header http.Header 17 | } 18 | 19 | func Get(url string, timeout int) ResponseWrapper { 20 | req, err := http.NewRequest("GET", url, nil) 21 | if err != nil { 22 | return createRequestError(err) 23 | } 24 | 25 | return request(req, timeout) 26 | } 27 | 28 | func PostParams(url string, params string, timeout int) ResponseWrapper { 29 | buf := bytes.NewBufferString(params) 30 | req, err := http.NewRequest("POST", url, buf) 31 | if err != nil { 32 | return createRequestError(err) 33 | } 34 | req.Header.Set("Content-type", "application/x-www-form-urlencoded") 35 | 36 | return request(req, timeout) 37 | } 38 | 39 | func PostJson(url string, body string, timeout int) ResponseWrapper { 40 | buf := bytes.NewBufferString(body) 41 | req, err := http.NewRequest("POST", url, buf) 42 | if err != nil { 43 | return createRequestError(err) 44 | } 45 | req.Header.Set("Content-type", "application/json") 46 | 47 | return request(req, timeout) 48 | } 49 | 50 | func request(req *http.Request, timeout int) ResponseWrapper { 51 | wrapper := ResponseWrapper{StatusCode: 0, Body: "", Header: make(http.Header)} 52 | client := &http.Client{} 53 | if timeout > 0 { 54 | client.Timeout = time.Duration(timeout) * time.Second 55 | } 56 | setRequestHeader(req) 57 | resp, err := client.Do(req) 58 | if err != nil { 59 | wrapper.Body = fmt.Sprintf("执行HTTP请求错误-%s", err.Error()) 60 | return wrapper 61 | } 62 | defer resp.Body.Close() 63 | body, err := ioutil.ReadAll(resp.Body) 64 | if err != nil { 65 | wrapper.Body = fmt.Sprintf("读取HTTP请求返回值失败-%s", err.Error()) 66 | return wrapper 67 | } 68 | wrapper.StatusCode = resp.StatusCode 69 | wrapper.Body = string(body) 70 | wrapper.Header = resp.Header 71 | 72 | return wrapper 73 | } 74 | 75 | func setRequestHeader(req *http.Request) { 76 | req.Header.Set("User-Agent", "golang/gocron") 77 | } 78 | 79 | func createRequestError(err error) ResponseWrapper { 80 | errorMessage := fmt.Sprintf("创建HTTP请求错误-%s", err.Error()) 81 | return ResponseWrapper{0, errorMessage, make(http.Header)} 82 | } 83 | -------------------------------------------------------------------------------- /internal/modules/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "runtime" 7 | 8 | "github.com/cihub/seelog" 9 | "gopkg.in/macaron.v1" 10 | ) 11 | 12 | // 日志库 13 | 14 | type Level int8 15 | 16 | var logger seelog.LoggerInterface 17 | 18 | const ( 19 | DEBUG = iota 20 | INFO 21 | WARN 22 | ERROR 23 | FATAL 24 | ) 25 | 26 | func InitLogger() { 27 | config := getLogConfig() 28 | l, err := seelog.LoggerFromConfigAsString(config) 29 | if err != nil { 30 | panic(err) 31 | } 32 | logger = l 33 | } 34 | 35 | func Debug(v ...interface{}) { 36 | if macaron.Env != macaron.DEV { 37 | return 38 | } 39 | write(DEBUG, v) 40 | } 41 | 42 | func Debugf(format string, v ...interface{}) { 43 | if macaron.Env != macaron.DEV { 44 | return 45 | } 46 | writef(DEBUG, format, v...) 47 | } 48 | 49 | func Info(v ...interface{}) { 50 | write(INFO, v) 51 | } 52 | 53 | func Infof(format string, v ...interface{}) { 54 | writef(INFO, format, v...) 55 | } 56 | 57 | func Warn(v ...interface{}) { 58 | write(WARN, v) 59 | } 60 | 61 | func Warnf(format string, v ...interface{}) { 62 | writef(WARN, format, v...) 63 | } 64 | 65 | func Error(v ...interface{}) { 66 | write(ERROR, v) 67 | } 68 | 69 | func Errorf(format string, v ...interface{}) { 70 | writef(ERROR, format, v...) 71 | } 72 | 73 | func Fatal(v ...interface{}) { 74 | write(FATAL, v) 75 | } 76 | 77 | func Fatalf(format string, v ...interface{}) { 78 | writef(FATAL, format, v...) 79 | } 80 | 81 | func write(level Level, v ...interface{}) { 82 | defer logger.Flush() 83 | 84 | content := "" 85 | if macaron.Env == macaron.DEV { 86 | pc, file, line, ok := runtime.Caller(2) 87 | if ok { 88 | content = fmt.Sprintf("#%s#%s#%d行#", file, runtime.FuncForPC(pc).Name(), line) 89 | } 90 | } 91 | 92 | switch level { 93 | case DEBUG: 94 | logger.Debug(content, v) 95 | case INFO: 96 | logger.Info(content, v) 97 | case WARN: 98 | logger.Warn(content, v) 99 | case FATAL: 100 | logger.Critical(content, v) 101 | os.Exit(1) 102 | case ERROR: 103 | logger.Error(content, v) 104 | } 105 | } 106 | 107 | func writef(level Level, format string, v ...interface{}) { 108 | defer logger.Flush() 109 | 110 | content := "" 111 | if macaron.Env == macaron.DEV { 112 | pc, file, line, ok := runtime.Caller(2) 113 | if ok { 114 | content = fmt.Sprintf("#%s#%s#%d行#", file, runtime.FuncForPC(pc).Name(), line) 115 | } 116 | } 117 | 118 | format = content + format 119 | 120 | switch level { 121 | case DEBUG: 122 | logger.Debugf(format, v...) 123 | case INFO: 124 | logger.Infof(format, v...) 125 | case WARN: 126 | logger.Warnf(format, v...) 127 | case FATAL: 128 | logger.Criticalf(format, v...) 129 | os.Exit(1) 130 | case ERROR: 131 | logger.Errorf(format, v...) 132 | } 133 | } 134 | 135 | func getLogConfig() string { 136 | config := ` 137 | 138 | 139 | %s 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | ` 148 | 149 | consoleConfig := "" 150 | if macaron.Env == macaron.DEV { 151 | consoleConfig = 152 | ` 153 | 154 | 155 | 156 | ` 157 | } 158 | config = fmt.Sprintf(config, consoleConfig) 159 | 160 | return config 161 | } 162 | -------------------------------------------------------------------------------- /internal/modules/notify/mail.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | "time" 7 | 8 | "github.com/go-gomail/gomail" 9 | "github.com/ouqiang/gocron/internal/models" 10 | "github.com/ouqiang/gocron/internal/modules/logger" 11 | "github.com/ouqiang/gocron/internal/modules/utils" 12 | ) 13 | 14 | // @author qiang.ou 15 | // @date 2017/5/1-00:19 16 | 17 | type Mail struct { 18 | } 19 | 20 | func (mail *Mail) Send(msg Message) { 21 | model := new(models.Setting) 22 | mailSetting, err := model.Mail() 23 | logger.Debugf("%+v", mailSetting) 24 | if err != nil { 25 | logger.Error("#mail#从数据库获取mail配置失败", err) 26 | return 27 | } 28 | if mailSetting.Host == "" { 29 | logger.Error("#mail#Host为空") 30 | return 31 | } 32 | if mailSetting.Port == 0 { 33 | logger.Error("#mail#Port为空") 34 | return 35 | } 36 | if mailSetting.User == "" { 37 | logger.Error("#mail#User为空") 38 | return 39 | } 40 | if mailSetting.Password == "" { 41 | logger.Error("#mail#Password为空") 42 | return 43 | } 44 | msg["content"] = parseNotifyTemplate(mailSetting.Template, msg) 45 | toUsers := mail.getActiveMailUsers(mailSetting, msg) 46 | mail.send(mailSetting, toUsers, msg) 47 | } 48 | 49 | func (mail *Mail) send(mailSetting models.Mail, toUsers []string, msg Message) { 50 | body := msg["content"].(string) 51 | body = strings.Replace(body, "\n", "
", -1) 52 | gomailMessage := gomail.NewMessage() 53 | gomailMessage.SetHeader("From", mailSetting.User) 54 | gomailMessage.SetHeader("To", toUsers...) 55 | gomailMessage.SetHeader("Subject", "gocron-定时任务通知") 56 | gomailMessage.SetBody("text/html", body) 57 | mailer := gomail.NewDialer(mailSetting.Host, mailSetting.Port, 58 | mailSetting.User, mailSetting.Password) 59 | maxTimes := 3 60 | i := 0 61 | for i < maxTimes { 62 | err := mailer.DialAndSend(gomailMessage) 63 | if err == nil { 64 | break 65 | } 66 | i += 1 67 | time.Sleep(2 * time.Second) 68 | if i < maxTimes { 69 | logger.Errorf("mail#发送消息失败#%s#消息内容-%s", err.Error(), msg["content"]) 70 | } 71 | } 72 | } 73 | 74 | func (mail *Mail) getActiveMailUsers(mailSetting models.Mail, msg Message) []string { 75 | taskReceiverIds := strings.Split(msg["task_receiver_id"].(string), ",") 76 | users := []string{} 77 | for _, v := range mailSetting.MailUsers { 78 | if utils.InStringSlice(taskReceiverIds, strconv.Itoa(v.Id)) { 79 | users = append(users, v.Email) 80 | } 81 | } 82 | 83 | return users 84 | } 85 | -------------------------------------------------------------------------------- /internal/modules/notify/notify.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "html/template" 7 | "time" 8 | 9 | "github.com/ouqiang/gocron/internal/modules/logger" 10 | ) 11 | 12 | type Message map[string]interface{} 13 | 14 | type Notifiable interface { 15 | Send(msg Message) 16 | } 17 | 18 | var queue = make(chan Message, 100) 19 | 20 | func init() { 21 | go run() 22 | } 23 | 24 | // 把消息推入队列 25 | func Push(msg Message) { 26 | queue <- msg 27 | } 28 | 29 | func run() { 30 | for msg := range queue { 31 | // 根据任务配置发送通知 32 | taskType, taskTypeOk := msg["task_type"] 33 | _, taskReceiverIdOk := msg["task_receiver_id"] 34 | _, nameOk := msg["name"] 35 | _, outputOk := msg["output"] 36 | _, statusOk := msg["status"] 37 | if !taskTypeOk || !taskReceiverIdOk || !nameOk || !outputOk || !statusOk { 38 | logger.Errorf("#notify#参数不完整#%+v", msg) 39 | continue 40 | } 41 | msg["content"] = fmt.Sprintf("============\n============\n============\n任务名称: %s\n状态: %s\n输出:\n %s\n", msg["name"], msg["status"], msg["output"]) 42 | logger.Debugf("%+v", msg) 43 | switch taskType.(int8) { 44 | case 1: 45 | // 邮件 46 | mail := Mail{} 47 | go mail.Send(msg) 48 | case 2: 49 | // Slack 50 | slack := Slack{} 51 | go slack.Send(msg) 52 | case 3: 53 | // WebHook 54 | webHook := WebHook{} 55 | go webHook.Send(msg) 56 | } 57 | time.Sleep(1 * time.Second) 58 | } 59 | } 60 | 61 | func parseNotifyTemplate(notifyTemplate string, msg Message) string { 62 | tmpl, err := template.New("notify").Parse(notifyTemplate) 63 | if err != nil { 64 | return fmt.Sprintf("解析通知模板失败: %s", err) 65 | } 66 | var buf bytes.Buffer 67 | tmpl.Execute(&buf, map[string]interface{}{ 68 | "TaskId": msg["task_id"], 69 | "TaskName": msg["name"], 70 | "Status": msg["status"], 71 | "Result": msg["output"], 72 | "Remark": msg["remark"], 73 | }) 74 | 75 | return buf.String() 76 | } 77 | -------------------------------------------------------------------------------- /internal/modules/notify/slack.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | // 发送消息到slack 4 | 5 | import ( 6 | "fmt" 7 | "html" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/ouqiang/gocron/internal/models" 13 | "github.com/ouqiang/gocron/internal/modules/httpclient" 14 | "github.com/ouqiang/gocron/internal/modules/logger" 15 | "github.com/ouqiang/gocron/internal/modules/utils" 16 | ) 17 | 18 | type Slack struct{} 19 | 20 | func (slack *Slack) Send(msg Message) { 21 | model := new(models.Setting) 22 | slackSetting, err := model.Slack() 23 | if err != nil { 24 | logger.Error("#slack#从数据库获取slack配置失败", err) 25 | return 26 | } 27 | if slackSetting.Url == "" { 28 | logger.Error("#slack#webhook-url为空") 29 | return 30 | } 31 | if len(slackSetting.Channels) == 0 { 32 | logger.Error("#slack#channels配置为空") 33 | return 34 | } 35 | logger.Debugf("%+v", slackSetting) 36 | channels := slack.getActiveSlackChannels(slackSetting, msg) 37 | logger.Debugf("%+v", channels) 38 | msg["content"] = parseNotifyTemplate(slackSetting.Template, msg) 39 | msg["content"] = html.UnescapeString(msg["content"].(string)) 40 | for _, channel := range channels { 41 | slack.send(msg, slackSetting.Url, channel) 42 | } 43 | } 44 | 45 | func (slack *Slack) send(msg Message, slackUrl string, channel string) { 46 | formatBody := slack.format(msg["content"].(string), channel) 47 | timeout := 30 48 | maxTimes := 3 49 | i := 0 50 | for i < maxTimes { 51 | resp := httpclient.PostJson(slackUrl, formatBody, timeout) 52 | if resp.StatusCode == 200 { 53 | break 54 | } 55 | i += 1 56 | time.Sleep(2 * time.Second) 57 | if i < maxTimes { 58 | logger.Errorf("slack#发送消息失败#%s#消息内容-%s", resp.Body, msg["content"]) 59 | } 60 | } 61 | } 62 | 63 | func (slack *Slack) getActiveSlackChannels(slackSetting models.Slack, msg Message) []string { 64 | taskReceiverIds := strings.Split(msg["task_receiver_id"].(string), ",") 65 | channels := []string{} 66 | for _, v := range slackSetting.Channels { 67 | if utils.InStringSlice(taskReceiverIds, strconv.Itoa(v.Id)) { 68 | channels = append(channels, v.Name) 69 | } 70 | } 71 | 72 | return channels 73 | } 74 | 75 | // 格式化消息内容 76 | func (slack *Slack) format(content string, channel string) string { 77 | content = utils.EscapeJson(content) 78 | specialChars := []string{"&", "<", ">"} 79 | replaceChars := []string{"&", "<", ">"} 80 | content = utils.ReplaceStrings(content, specialChars, replaceChars) 81 | 82 | return fmt.Sprintf(`{"text":"%s","username":"gocron", "channel":"%s"}`, content, channel) 83 | } 84 | -------------------------------------------------------------------------------- /internal/modules/notify/webhook.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "html" 5 | "time" 6 | 7 | "github.com/ouqiang/gocron/internal/models" 8 | "github.com/ouqiang/gocron/internal/modules/httpclient" 9 | "github.com/ouqiang/gocron/internal/modules/logger" 10 | "github.com/ouqiang/gocron/internal/modules/utils" 11 | ) 12 | 13 | type WebHook struct{} 14 | 15 | func (webHook *WebHook) Send(msg Message) { 16 | model := new(models.Setting) 17 | webHookSetting, err := model.Webhook() 18 | if err != nil { 19 | logger.Error("#webHook#从数据库获取webHook配置失败", err) 20 | return 21 | } 22 | if webHookSetting.Url == "" { 23 | logger.Error("#webHook#webhook-url为空") 24 | return 25 | } 26 | logger.Debugf("%+v", webHookSetting) 27 | msg["name"] = utils.EscapeJson(msg["name"].(string)) 28 | msg["output"] = utils.EscapeJson(msg["output"].(string)) 29 | msg["content"] = parseNotifyTemplate(webHookSetting.Template, msg) 30 | msg["content"] = html.UnescapeString(msg["content"].(string)) 31 | webHook.send(msg, webHookSetting.Url) 32 | } 33 | 34 | func (webHook *WebHook) send(msg Message, url string) { 35 | content := msg["content"].(string) 36 | timeout := 30 37 | maxTimes := 3 38 | i := 0 39 | for i < maxTimes { 40 | resp := httpclient.PostJson(url, content, timeout) 41 | if resp.StatusCode == 200 { 42 | break 43 | } 44 | i += 1 45 | time.Sleep(2 * time.Second) 46 | if i < maxTimes { 47 | logger.Errorf("webHook#发送消息失败#%s#消息内容-%s", resp.Body, msg["content"]) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /internal/modules/rpc/auth/Certification.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "errors" 7 | "fmt" 8 | "io/ioutil" 9 | 10 | "google.golang.org/grpc/credentials" 11 | ) 12 | 13 | type Certificate struct { 14 | CAFile string 15 | CertFile string 16 | KeyFile string 17 | ServerName string 18 | } 19 | 20 | func (c Certificate) GetTLSConfigForServer() (*tls.Config, error) { 21 | certificate, err := tls.LoadX509KeyPair( 22 | c.CertFile, 23 | c.KeyFile, 24 | ) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | certPool := x509.NewCertPool() 30 | bs, err := ioutil.ReadFile(c.CAFile) 31 | if err != nil { 32 | return nil, fmt.Errorf("failed to read client ca cert: %s", err) 33 | } 34 | 35 | ok := certPool.AppendCertsFromPEM(bs) 36 | if !ok { 37 | return nil, errors.New("failed to append client certs") 38 | } 39 | 40 | tlsConfig := &tls.Config{ 41 | ClientAuth: tls.RequireAndVerifyClientCert, 42 | Certificates: []tls.Certificate{certificate}, 43 | ClientCAs: certPool, 44 | } 45 | 46 | return tlsConfig, nil 47 | } 48 | 49 | func (c Certificate) GetTransportCredsForClient() (credentials.TransportCredentials, error) { 50 | certificate, err := tls.LoadX509KeyPair( 51 | c.CertFile, 52 | c.KeyFile, 53 | ) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | certPool := x509.NewCertPool() 59 | bs, err := ioutil.ReadFile(c.CAFile) 60 | if err != nil { 61 | return nil, fmt.Errorf("failed to read ca cert: %s", err) 62 | } 63 | 64 | ok := certPool.AppendCertsFromPEM(bs) 65 | if !ok { 66 | return nil, errors.New("failed to append certs") 67 | } 68 | 69 | transportCreds := credentials.NewTLS(&tls.Config{ 70 | ServerName: c.ServerName, 71 | Certificates: []tls.Certificate{certificate}, 72 | RootCAs: certPool, 73 | }) 74 | 75 | return transportCreds, nil 76 | } 77 | -------------------------------------------------------------------------------- /internal/modules/rpc/client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "sync" 7 | "time" 8 | 9 | "google.golang.org/grpc/status" 10 | 11 | "github.com/ouqiang/gocron/internal/modules/logger" 12 | "github.com/ouqiang/gocron/internal/modules/rpc/grpcpool" 13 | pb "github.com/ouqiang/gocron/internal/modules/rpc/proto" 14 | "golang.org/x/net/context" 15 | "google.golang.org/grpc/codes" 16 | ) 17 | 18 | var ( 19 | taskMap sync.Map 20 | ) 21 | 22 | var ( 23 | errUnavailable = errors.New("无法连接远程服务器") 24 | ) 25 | 26 | func generateTaskUniqueKey(ip string, port int, id int64) string { 27 | return fmt.Sprintf("%s:%d:%d", ip, port, id) 28 | } 29 | 30 | func Stop(ip string, port int, id int64) { 31 | key := generateTaskUniqueKey(ip, port, id) 32 | cancel, ok := taskMap.Load(key) 33 | if !ok { 34 | return 35 | } 36 | cancel.(context.CancelFunc)() 37 | } 38 | 39 | func Exec(ip string, port int, taskReq *pb.TaskRequest) (string, error) { 40 | defer func() { 41 | if err := recover(); err != nil { 42 | logger.Error("panic#rpc/client.go:Exec#", err) 43 | } 44 | }() 45 | addr := fmt.Sprintf("%s:%d", ip, port) 46 | c, err := grpcpool.Pool.Get(addr) 47 | if err != nil { 48 | return "", err 49 | } 50 | if taskReq.Timeout <= 0 || taskReq.Timeout > 86400 { 51 | taskReq.Timeout = 86400 52 | } 53 | timeout := time.Duration(taskReq.Timeout) * time.Second 54 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 55 | defer cancel() 56 | 57 | taskUniqueKey := generateTaskUniqueKey(ip, port, taskReq.Id) 58 | taskMap.Store(taskUniqueKey, cancel) 59 | defer taskMap.Delete(taskUniqueKey) 60 | 61 | resp, err := c.Run(ctx, taskReq) 62 | if err != nil { 63 | return parseGRPCError(err) 64 | } 65 | 66 | if resp.Error == "" { 67 | return resp.Output, nil 68 | } 69 | 70 | return resp.Output, errors.New(resp.Error) 71 | } 72 | 73 | func parseGRPCError(err error) (string, error) { 74 | switch status.Code(err) { 75 | case codes.Unavailable: 76 | return "", errUnavailable 77 | case codes.DeadlineExceeded: 78 | return "", errors.New("执行超时, 强制结束") 79 | case codes.Canceled: 80 | return "", errors.New("手动停止") 81 | } 82 | return "", err 83 | } 84 | -------------------------------------------------------------------------------- /internal/modules/rpc/grpcpool/grpc_pool.go: -------------------------------------------------------------------------------- 1 | package grpcpool 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "sync" 7 | "time" 8 | 9 | "github.com/ouqiang/gocron/internal/modules/app" 10 | "github.com/ouqiang/gocron/internal/modules/rpc/auth" 11 | "github.com/ouqiang/gocron/internal/modules/rpc/proto" 12 | "google.golang.org/grpc" 13 | "google.golang.org/grpc/keepalive" 14 | ) 15 | 16 | const ( 17 | backOffMaxDelay = 3 * time.Second 18 | dialTimeout = 2 * time.Second 19 | ) 20 | 21 | var ( 22 | Pool = &GRPCPool{ 23 | conns: make(map[string]*Client), 24 | } 25 | 26 | keepAliveParams = keepalive.ClientParameters{ 27 | Time: 20 * time.Second, 28 | Timeout: 3 * time.Second, 29 | PermitWithoutStream: true, 30 | } 31 | ) 32 | 33 | type Client struct { 34 | conn *grpc.ClientConn 35 | rpcClient rpc.TaskClient 36 | } 37 | 38 | type GRPCPool struct { 39 | // map key格式 ip:port 40 | conns map[string]*Client 41 | mu sync.RWMutex 42 | } 43 | 44 | func (p *GRPCPool) Get(addr string) (rpc.TaskClient, error) { 45 | p.mu.RLock() 46 | client, ok := p.conns[addr] 47 | p.mu.RUnlock() 48 | if ok { 49 | return client.rpcClient, nil 50 | } 51 | 52 | client, err := p.factory(addr) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | return client.rpcClient, nil 58 | } 59 | 60 | // 释放连接 61 | func (p *GRPCPool) Release(addr string) { 62 | p.mu.Lock() 63 | defer p.mu.Unlock() 64 | client, ok := p.conns[addr] 65 | if !ok { 66 | return 67 | } 68 | delete(p.conns, addr) 69 | client.conn.Close() 70 | } 71 | 72 | // 创建连接 73 | func (p *GRPCPool) factory(addr string) (*Client, error) { 74 | p.mu.Lock() 75 | defer p.mu.Unlock() 76 | 77 | client, ok := p.conns[addr] 78 | if ok { 79 | return client, nil 80 | } 81 | opts := []grpc.DialOption{ 82 | grpc.WithKeepaliveParams(keepAliveParams), 83 | grpc.WithBackoffMaxDelay(backOffMaxDelay), 84 | } 85 | 86 | if !app.Setting.EnableTLS { 87 | opts = append(opts, grpc.WithInsecure()) 88 | } else { 89 | server := strings.Split(addr, ":") 90 | certificate := auth.Certificate{ 91 | CAFile: app.Setting.CAFile, 92 | CertFile: app.Setting.CertFile, 93 | KeyFile: app.Setting.KeyFile, 94 | ServerName: server[0], 95 | } 96 | 97 | transportCreds, err := certificate.GetTransportCredsForClient() 98 | if err != nil { 99 | return nil, err 100 | } 101 | opts = append(opts, grpc.WithTransportCredentials(transportCreds)) 102 | } 103 | 104 | ctx, cancel := context.WithTimeout(context.Background(), dialTimeout) 105 | defer cancel() 106 | 107 | conn, err := grpc.DialContext(ctx, addr, opts...) 108 | if err != nil { 109 | return nil, err 110 | } 111 | 112 | client = &Client{ 113 | conn: conn, 114 | rpcClient: rpc.NewTaskClient(conn), 115 | } 116 | 117 | p.conns[addr] = client 118 | 119 | return client, nil 120 | } 121 | -------------------------------------------------------------------------------- /internal/modules/rpc/proto/task.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // source: task.proto 3 | 4 | /* 5 | Package rpc is a generated protocol buffer package. 6 | 7 | It is generated from these files: 8 | task.proto 9 | 10 | It has these top-level messages: 11 | TaskRequest 12 | TaskResponse 13 | */ 14 | package rpc 15 | 16 | import proto "github.com/golang/protobuf/proto" 17 | import fmt "fmt" 18 | import math "math" 19 | 20 | import ( 21 | context "golang.org/x/net/context" 22 | grpc "google.golang.org/grpc" 23 | ) 24 | 25 | // Reference imports to suppress errors if they are not otherwise used. 26 | var _ = proto.Marshal 27 | var _ = fmt.Errorf 28 | var _ = math.Inf 29 | 30 | // This is a compile-time assertion to ensure that this generated file 31 | // is compatible with the proto package it is being compiled against. 32 | // A compilation error at this line likely means your copy of the 33 | // proto package needs to be updated. 34 | const _ = proto.ProtoPackageIsVersion2 // please upgrade the proto package 35 | 36 | type TaskRequest struct { 37 | Command string `protobuf:"bytes,2,opt,name=command" json:"command,omitempty"` 38 | Timeout int32 `protobuf:"varint,3,opt,name=timeout" json:"timeout,omitempty"` 39 | Id int64 `protobuf:"varint,4,opt,name=id" json:"id,omitempty"` 40 | } 41 | 42 | func (m *TaskRequest) Reset() { *m = TaskRequest{} } 43 | func (m *TaskRequest) String() string { return proto.CompactTextString(m) } 44 | func (*TaskRequest) ProtoMessage() {} 45 | func (*TaskRequest) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{0} } 46 | 47 | func (m *TaskRequest) GetCommand() string { 48 | if m != nil { 49 | return m.Command 50 | } 51 | return "" 52 | } 53 | 54 | func (m *TaskRequest) GetTimeout() int32 { 55 | if m != nil { 56 | return m.Timeout 57 | } 58 | return 0 59 | } 60 | 61 | func (m *TaskRequest) GetId() int64 { 62 | if m != nil { 63 | return m.Id 64 | } 65 | return 0 66 | } 67 | 68 | type TaskResponse struct { 69 | Output string `protobuf:"bytes,1,opt,name=output" json:"output,omitempty"` 70 | Error string `protobuf:"bytes,2,opt,name=error" json:"error,omitempty"` 71 | } 72 | 73 | func (m *TaskResponse) Reset() { *m = TaskResponse{} } 74 | func (m *TaskResponse) String() string { return proto.CompactTextString(m) } 75 | func (*TaskResponse) ProtoMessage() {} 76 | func (*TaskResponse) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{1} } 77 | 78 | func (m *TaskResponse) GetOutput() string { 79 | if m != nil { 80 | return m.Output 81 | } 82 | return "" 83 | } 84 | 85 | func (m *TaskResponse) GetError() string { 86 | if m != nil { 87 | return m.Error 88 | } 89 | return "" 90 | } 91 | 92 | func init() { 93 | proto.RegisterType((*TaskRequest)(nil), "rpc.TaskRequest") 94 | proto.RegisterType((*TaskResponse)(nil), "rpc.TaskResponse") 95 | } 96 | 97 | // Reference imports to suppress errors if they are not otherwise used. 98 | var _ context.Context 99 | var _ grpc.ClientConn 100 | 101 | // This is a compile-time assertion to ensure that this generated file 102 | // is compatible with the grpc package it is being compiled against. 103 | const _ = grpc.SupportPackageIsVersion4 104 | 105 | // Client API for Task service 106 | 107 | type TaskClient interface { 108 | Run(ctx context.Context, in *TaskRequest, opts ...grpc.CallOption) (*TaskResponse, error) 109 | } 110 | 111 | type taskClient struct { 112 | cc *grpc.ClientConn 113 | } 114 | 115 | func NewTaskClient(cc *grpc.ClientConn) TaskClient { 116 | return &taskClient{cc} 117 | } 118 | 119 | func (c *taskClient) Run(ctx context.Context, in *TaskRequest, opts ...grpc.CallOption) (*TaskResponse, error) { 120 | out := new(TaskResponse) 121 | err := grpc.Invoke(ctx, "/rpc.Task/Run", in, out, c.cc, opts...) 122 | if err != nil { 123 | return nil, err 124 | } 125 | return out, nil 126 | } 127 | 128 | // Server API for Task service 129 | 130 | type TaskServer interface { 131 | Run(context.Context, *TaskRequest) (*TaskResponse, error) 132 | } 133 | 134 | func RegisterTaskServer(s *grpc.Server, srv TaskServer) { 135 | s.RegisterService(&_Task_serviceDesc, srv) 136 | } 137 | 138 | func _Task_Run_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 139 | in := new(TaskRequest) 140 | if err := dec(in); err != nil { 141 | return nil, err 142 | } 143 | if interceptor == nil { 144 | return srv.(TaskServer).Run(ctx, in) 145 | } 146 | info := &grpc.UnaryServerInfo{ 147 | Server: srv, 148 | FullMethod: "/rpc.Task/Run", 149 | } 150 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 151 | return srv.(TaskServer).Run(ctx, req.(*TaskRequest)) 152 | } 153 | return interceptor(ctx, in, info, handler) 154 | } 155 | 156 | var _Task_serviceDesc = grpc.ServiceDesc{ 157 | ServiceName: "rpc.Task", 158 | HandlerType: (*TaskServer)(nil), 159 | Methods: []grpc.MethodDesc{ 160 | { 161 | MethodName: "Run", 162 | Handler: _Task_Run_Handler, 163 | }, 164 | }, 165 | Streams: []grpc.StreamDesc{}, 166 | Metadata: "task.proto", 167 | } 168 | 169 | func init() { proto.RegisterFile("task.proto", fileDescriptor0) } 170 | 171 | var fileDescriptor0 = []byte{ 172 | // 184 bytes of a gzipped FileDescriptorProto 173 | 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x4c, 0x8f, 0xcf, 0x8a, 0x83, 0x30, 174 | 0x10, 0xc6, 0x37, 0x46, 0x5d, 0x76, 0x76, 0x59, 0xda, 0xa1, 0x94, 0xd0, 0x93, 0x78, 0xf2, 0x50, 175 | 0x3c, 0xb4, 0x3d, 0xf6, 0x25, 0x1a, 0xfa, 0x02, 0x56, 0x73, 0x10, 0xd1, 0x49, 0x93, 0xc9, 0xfb, 176 | 0x17, 0xff, 0x81, 0xc7, 0xdf, 0x0c, 0xf3, 0xfd, 0xe6, 0x03, 0xe0, 0xca, 0x77, 0xa5, 0x75, 0xc4, 177 | 0x84, 0xd2, 0xd9, 0x3a, 0x7f, 0xc0, 0xef, 0xb3, 0xf2, 0x9d, 0x36, 0xef, 0x60, 0x3c, 0xa3, 0x82, 178 | 0xef, 0x9a, 0xfa, 0xbe, 0x1a, 0x1a, 0x15, 0x65, 0xa2, 0xf8, 0xd1, 0x2b, 0x8e, 0x1b, 0x6e, 0x7b, 179 | 0x43, 0x81, 0x95, 0xcc, 0x44, 0x91, 0xe8, 0x15, 0xf1, 0x1f, 0xa2, 0xb6, 0x51, 0x71, 0x26, 0x0a, 180 | 0xa9, 0xa3, 0xb6, 0xc9, 0xef, 0xf0, 0x37, 0x47, 0x7a, 0x4b, 0x83, 0x37, 0x78, 0x84, 0x94, 0x02, 181 | 0xdb, 0xc0, 0x4a, 0x4c, 0x91, 0x0b, 0xe1, 0x01, 0x12, 0xe3, 0x1c, 0xb9, 0xc5, 0x34, 0xc3, 0xe5, 182 | 0x06, 0xf1, 0x78, 0x8d, 0x67, 0x90, 0x3a, 0x0c, 0xb8, 0x2b, 0x9d, 0xad, 0xcb, 0xcd, 0x8b, 0xa7, 183 | 0xfd, 0x66, 0x32, 0x1b, 0xf2, 0xaf, 0x57, 0x3a, 0x55, 0xba, 0x7e, 0x02, 0x00, 0x00, 0xff, 0xff, 184 | 0xd7, 0x7f, 0x8a, 0x9d, 0xe0, 0x00, 0x00, 0x00, 185 | } 186 | -------------------------------------------------------------------------------- /internal/modules/rpc/proto/task.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package rpc; 4 | 5 | service Task { 6 | rpc Run(TaskRequest) returns (TaskResponse) {} 7 | } 8 | 9 | message TaskRequest { 10 | string command = 2; // 命令 11 | int32 timeout = 3; // 任务执行超时时间 12 | int64 id = 4; // 执行任务唯一ID 13 | } 14 | 15 | message TaskResponse { 16 | string output = 1; // 命令标准输出 17 | string error = 2; // 命令错误 18 | } -------------------------------------------------------------------------------- /internal/modules/rpc/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net" 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | "time" 9 | 10 | "github.com/ouqiang/gocron/internal/modules/rpc/auth" 11 | pb "github.com/ouqiang/gocron/internal/modules/rpc/proto" 12 | "github.com/ouqiang/gocron/internal/modules/utils" 13 | log "github.com/sirupsen/logrus" 14 | "golang.org/x/net/context" 15 | "google.golang.org/grpc" 16 | "google.golang.org/grpc/credentials" 17 | "google.golang.org/grpc/keepalive" 18 | ) 19 | 20 | type Server struct{} 21 | 22 | var keepAlivePolicy = keepalive.EnforcementPolicy{ 23 | MinTime: 10 * time.Second, 24 | PermitWithoutStream: true, 25 | } 26 | 27 | var keepAliveParams = keepalive.ServerParameters{ 28 | MaxConnectionIdle: 30 * time.Second, 29 | Time: 30 * time.Second, 30 | Timeout: 3 * time.Second, 31 | } 32 | 33 | func (s Server) Run(ctx context.Context, req *pb.TaskRequest) (*pb.TaskResponse, error) { 34 | defer func() { 35 | if err := recover(); err != nil { 36 | log.Error(err) 37 | } 38 | }() 39 | log.Infof("execute cmd start: [id: %d cmd: %s]", req.Id, req.Command) 40 | output, err := utils.ExecShell(ctx, req.Command) 41 | resp := new(pb.TaskResponse) 42 | resp.Output = output 43 | if err != nil { 44 | resp.Error = err.Error() 45 | } else { 46 | resp.Error = "" 47 | } 48 | log.Infof("execute cmd end: [id: %d cmd: %s err: %s]", req.Id, req.Command, resp.Error) 49 | 50 | return resp, nil 51 | } 52 | 53 | func Start(addr string, enableTLS bool, certificate auth.Certificate) { 54 | l, err := net.Listen("tcp", addr) 55 | if err != nil { 56 | log.Fatal(err) 57 | } 58 | opts := []grpc.ServerOption{ 59 | grpc.KeepaliveParams(keepAliveParams), 60 | grpc.KeepaliveEnforcementPolicy(keepAlivePolicy), 61 | } 62 | if enableTLS { 63 | tlsConfig, err := certificate.GetTLSConfigForServer() 64 | if err != nil { 65 | log.Fatal(err) 66 | } 67 | opt := grpc.Creds(credentials.NewTLS(tlsConfig)) 68 | opts = append(opts, opt) 69 | } 70 | server := grpc.NewServer(opts...) 71 | pb.RegisterTaskServer(server, Server{}) 72 | log.Infof("server listen on %s", addr) 73 | 74 | go func() { 75 | err = server.Serve(l) 76 | if err != nil { 77 | log.Fatal(err) 78 | } 79 | }() 80 | 81 | c := make(chan os.Signal, 1) 82 | signal.Notify(c, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM) 83 | for { 84 | s := <-c 85 | log.Infoln("收到信号 -- ", s) 86 | switch s { 87 | case syscall.SIGHUP: 88 | log.Infoln("收到终端断开信号, 忽略") 89 | case syscall.SIGINT, syscall.SIGTERM: 90 | log.Info("应用准备退出") 91 | server.GracefulStop() 92 | return 93 | } 94 | } 95 | 96 | } 97 | -------------------------------------------------------------------------------- /internal/modules/setting/setting.go: -------------------------------------------------------------------------------- 1 | package setting 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/ouqiang/gocron/internal/modules/logger" 7 | "github.com/ouqiang/gocron/internal/modules/utils" 8 | "gopkg.in/ini.v1" 9 | ) 10 | 11 | const DefaultSection = "default" 12 | 13 | type Setting struct { 14 | Db struct { 15 | Engine string 16 | Host string 17 | Port int 18 | User string 19 | Password string 20 | Database string 21 | Prefix string 22 | Charset string 23 | MaxIdleConns int 24 | MaxOpenConns int 25 | } 26 | AllowIps string 27 | AppName string 28 | ApiKey string 29 | ApiSecret string 30 | ApiSignEnable bool 31 | 32 | EnableTLS bool 33 | CAFile string 34 | CertFile string 35 | KeyFile string 36 | 37 | ConcurrencyQueue int 38 | AuthSecret string 39 | } 40 | 41 | // 读取配置 42 | func Read(filename string) (*Setting, error) { 43 | config, err := ini.Load(filename) 44 | if err != nil { 45 | return nil, err 46 | } 47 | section := config.Section(DefaultSection) 48 | 49 | var s Setting 50 | 51 | s.Db.Engine = section.Key("db.engine").MustString("mysql") 52 | s.Db.Host = section.Key("db.host").MustString("127.0.0.1") 53 | s.Db.Port = section.Key("db.port").MustInt(3306) 54 | s.Db.User = section.Key("db.user").MustString("") 55 | s.Db.Password = section.Key("db.password").MustString("") 56 | s.Db.Database = section.Key("db.database").MustString("gocron") 57 | s.Db.Prefix = section.Key("db.prefix").MustString("") 58 | s.Db.Charset = section.Key("db.charset").MustString("utf8") 59 | s.Db.MaxIdleConns = section.Key("db.max.idle.conns").MustInt(30) 60 | s.Db.MaxOpenConns = section.Key("db.max.open.conns").MustInt(100) 61 | 62 | s.AllowIps = section.Key("allow_ips").MustString("") 63 | s.AppName = section.Key("app.name").MustString("定时任务管理系统") 64 | s.ApiKey = section.Key("api.key").MustString("") 65 | s.ApiSecret = section.Key("api.secret").MustString("") 66 | s.ApiSignEnable = section.Key("api.sign.enable").MustBool(true) 67 | s.ConcurrencyQueue = section.Key("concurrency.queue").MustInt(500) 68 | s.AuthSecret = section.Key("auth_secret").MustString("") 69 | if s.AuthSecret == "" { 70 | s.AuthSecret = utils.RandAuthToken() 71 | } 72 | 73 | s.EnableTLS = section.Key("enable_tls").MustBool(false) 74 | s.CAFile = section.Key("ca_file").MustString("") 75 | s.CertFile = section.Key("cert_file").MustString("") 76 | s.KeyFile = section.Key("key_file").MustString("") 77 | 78 | if s.EnableTLS { 79 | if !utils.FileExist(s.CAFile) { 80 | logger.Fatalf("failed to read ca cert file: %s", s.CAFile) 81 | } 82 | 83 | if !utils.FileExist(s.CertFile) { 84 | logger.Fatalf("failed to read client cert file: %s", s.CertFile) 85 | } 86 | 87 | if !utils.FileExist(s.KeyFile) { 88 | logger.Fatalf("failed to read client key file: %s", s.KeyFile) 89 | } 90 | } 91 | 92 | return &s, nil 93 | } 94 | 95 | // 写入配置 96 | func Write(config []string, filename string) error { 97 | if len(config) == 0 { 98 | return errors.New("参数不能为空") 99 | } 100 | if len(config)%2 != 0 { 101 | return errors.New("参数不匹配") 102 | } 103 | 104 | file := ini.Empty() 105 | 106 | section, err := file.NewSection(DefaultSection) 107 | if err != nil { 108 | return err 109 | } 110 | for i := 0; i < len(config); { 111 | _, err = section.NewKey(config[i], config[i+1]) 112 | if err != nil { 113 | return err 114 | } 115 | i += 2 116 | } 117 | err = file.SaveTo(filename) 118 | 119 | return err 120 | } 121 | -------------------------------------------------------------------------------- /internal/modules/utils/json.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/ouqiang/gocron/internal/modules/logger" 7 | ) 8 | 9 | // json 格式输出 10 | 11 | type response struct { 12 | Code int `json:"code"` // 状态码 0:成功 非0:失败 13 | Message string `json:"message"` // 信息 14 | Data interface{} `json:"data"` // 数据 15 | } 16 | 17 | type JsonResponse struct{} 18 | 19 | const ResponseSuccess = 0 20 | const ResponseFailure = 1 21 | const UnauthorizedError = 403 22 | const AuthError = 401 23 | const NotFound = 404 24 | const ServerError = 500 25 | const AppNotInstall = 801 26 | 27 | const SuccessContent = "操作成功" 28 | const FailureContent = "操作失败" 29 | 30 | func JsonResponseByErr(err error) string { 31 | jsonResp := JsonResponse{} 32 | if err != nil { 33 | return jsonResp.CommonFailure(FailureContent, err) 34 | } 35 | 36 | return jsonResp.Success(SuccessContent, nil) 37 | } 38 | 39 | func (j *JsonResponse) Success(message string, data interface{}) string { 40 | return j.response(ResponseSuccess, message, data) 41 | } 42 | 43 | func (j *JsonResponse) Failure(code int, message string) string { 44 | return j.response(code, message, nil) 45 | } 46 | 47 | func (j *JsonResponse) CommonFailure(message string, err ...error) string { 48 | if len(err) > 0 { 49 | logger.Warn(err) 50 | } 51 | return j.Failure(ResponseFailure, message) 52 | } 53 | 54 | func (j *JsonResponse) response(code int, message string, data interface{}) string { 55 | resp := response{ 56 | Code: code, 57 | Message: message, 58 | Data: data, 59 | } 60 | 61 | result, err := json.Marshal(resp) 62 | if err != nil { 63 | logger.Error(err) 64 | } 65 | 66 | return string(result) 67 | } 68 | -------------------------------------------------------------------------------- /internal/modules/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/md5" 5 | crand "crypto/rand" 6 | "encoding/hex" 7 | "fmt" 8 | "math/rand" 9 | "os" 10 | "strings" 11 | "time" 12 | 13 | "github.com/Tang-RoseChild/mahonia" 14 | ) 15 | 16 | func RandAuthToken() string { 17 | buf := make([]byte, 32) 18 | _, err := crand.Read(buf) 19 | if err != nil { 20 | return RandString(64) 21 | } 22 | 23 | return fmt.Sprintf("%x", buf) 24 | } 25 | 26 | // 生成长度为length的随机字符串 27 | func RandString(length int64) string { 28 | sources := []byte("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") 29 | var result []byte 30 | r := rand.New(rand.NewSource(time.Now().UnixNano())) 31 | sourceLength := len(sources) 32 | var i int64 = 0 33 | for ; i < length; i++ { 34 | result = append(result, sources[r.Intn(sourceLength)]) 35 | } 36 | 37 | return string(result) 38 | } 39 | 40 | // 生成32位MD5摘要 41 | func Md5(str string) string { 42 | m := md5.New() 43 | m.Write([]byte(str)) 44 | 45 | return hex.EncodeToString(m.Sum(nil)) 46 | } 47 | 48 | // 生成0-max之间随机数 49 | func RandNumber(max int) int { 50 | r := rand.New(rand.NewSource(time.Now().UnixNano())) 51 | 52 | return r.Intn(max) 53 | } 54 | 55 | // GBK编码转换为UTF8 56 | func GBK2UTF8(s string) (string, bool) { 57 | dec := mahonia.NewDecoder("gbk") 58 | 59 | return dec.ConvertStringOK(s) 60 | } 61 | 62 | // 批量替换字符串 63 | func ReplaceStrings(s string, old []string, replace []string) string { 64 | if s == "" { 65 | return s 66 | } 67 | if len(old) != len(replace) { 68 | return s 69 | } 70 | 71 | for i, v := range old { 72 | s = strings.Replace(s, v, replace[i], 1000) 73 | } 74 | 75 | return s 76 | } 77 | 78 | func InStringSlice(slice []string, element string) bool { 79 | element = strings.TrimSpace(element) 80 | for _, v := range slice { 81 | if strings.TrimSpace(v) == element { 82 | return true 83 | } 84 | } 85 | 86 | return false 87 | } 88 | 89 | // 转义json特殊字符 90 | func EscapeJson(s string) string { 91 | specialChars := []string{"\\", "\b", "\f", "\n", "\r", "\t", "\""} 92 | replaceChars := []string{"\\\\", "\\b", "\\f", "\\n", "\\r", "\\t", "\\\""} 93 | 94 | return ReplaceStrings(s, specialChars, replaceChars) 95 | } 96 | 97 | // 判断文件是否存在及是否有权限访问 98 | func FileExist(file string) bool { 99 | _, err := os.Stat(file) 100 | if os.IsNotExist(err) { 101 | return false 102 | } 103 | if os.IsPermission(err) { 104 | return false 105 | } 106 | 107 | return true 108 | } 109 | -------------------------------------------------------------------------------- /internal/modules/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "testing" 4 | 5 | func TestRandString(t *testing.T) { 6 | str := RandString(32) 7 | if len(str) != 32 { 8 | t.Fatalf("长度不匹配,目标长度32, 实际%d-%s", len(str), str) 9 | } 10 | } 11 | 12 | func TestMd5(t *testing.T) { 13 | str := Md5("123456") 14 | if len(str) != 32 { 15 | t.Fatalf("长度不匹配,目标长度32, 实际%d-%s", len(str), str) 16 | } 17 | } 18 | 19 | func TestRandNumber(t *testing.T) { 20 | num := RandNumber(10000) 21 | if num <= 0 && num >= 10000 { 22 | t.Fatalf("随机数不在有效范围内-%d", num) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /internal/modules/utils/utils_unix.go: -------------------------------------------------------------------------------- 1 | // +build !windows 2 | 3 | package utils 4 | 5 | import ( 6 | "errors" 7 | "os/exec" 8 | "syscall" 9 | 10 | "golang.org/x/net/context" 11 | ) 12 | 13 | type Result struct { 14 | output string 15 | err error 16 | } 17 | 18 | // 执行shell命令,可设置执行超时时间 19 | func ExecShell(ctx context.Context, command string) (string, error) { 20 | cmd := exec.Command("/bin/bash", "-c", command) 21 | cmd.SysProcAttr = &syscall.SysProcAttr{ 22 | Setpgid: true, 23 | } 24 | resultChan := make(chan Result) 25 | go func() { 26 | output, err := cmd.CombinedOutput() 27 | resultChan <- Result{string(output), err} 28 | }() 29 | select { 30 | case <-ctx.Done(): 31 | if cmd.Process.Pid > 0 { 32 | syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) 33 | } 34 | return "", errors.New("timeout killed") 35 | case result := <-resultChan: 36 | return result.output, result.err 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/modules/utils/utils_windows.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package utils 4 | 5 | import ( 6 | "errors" 7 | "os/exec" 8 | "strconv" 9 | "syscall" 10 | 11 | "golang.org/x/net/context" 12 | ) 13 | 14 | type Result struct { 15 | output string 16 | err error 17 | } 18 | 19 | // 执行shell命令,可设置执行超时时间 20 | func ExecShell(ctx context.Context, command string) (string, error) { 21 | cmd := exec.Command("cmd", "/C", command) 22 | // 隐藏cmd窗口 23 | cmd.SysProcAttr = &syscall.SysProcAttr{ 24 | HideWindow: true, 25 | } 26 | var resultChan chan Result = make(chan Result) 27 | go func() { 28 | output, err := cmd.CombinedOutput() 29 | resultChan <- Result{string(output), err} 30 | }() 31 | select { 32 | case <-ctx.Done(): 33 | if cmd.Process.Pid > 0 { 34 | exec.Command("taskkill", "/F", "/T", "/PID", strconv.Itoa(cmd.Process.Pid)).Run() 35 | cmd.Process.Kill() 36 | } 37 | return "", errors.New("timeout killed") 38 | case result := <-resultChan: 39 | return ConvertEncoding(result.output), result.err 40 | } 41 | } 42 | 43 | func ConvertEncoding(outputGBK string) string { 44 | // windows平台编码为gbk,需转换为utf8才能入库 45 | outputUTF8, ok := GBK2UTF8(outputGBK) 46 | if ok { 47 | return outputUTF8 48 | } 49 | 50 | return outputGBK 51 | } 52 | -------------------------------------------------------------------------------- /internal/routers/base/base.go: -------------------------------------------------------------------------------- 1 | package base 2 | 3 | import ( 4 | "github.com/ouqiang/gocron/internal/models" 5 | "gopkg.in/macaron.v1" 6 | ) 7 | 8 | // ParsePageAndPageSize 解析查询参数中的页数和每页数量 9 | func ParsePageAndPageSize(ctx *macaron.Context, params models.CommonMap) { 10 | page := ctx.QueryInt("page") 11 | pageSize := ctx.QueryInt("page_size") 12 | if page <= 0 { 13 | page = 1 14 | } 15 | if pageSize <= 0 { 16 | pageSize = models.PageSize 17 | } 18 | 19 | params["Page"] = page 20 | params["PageSize"] = pageSize 21 | } 22 | -------------------------------------------------------------------------------- /internal/routers/host/host.go: -------------------------------------------------------------------------------- 1 | package host 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/go-macaron/binding" 9 | "github.com/ouqiang/gocron/internal/models" 10 | "github.com/ouqiang/gocron/internal/modules/logger" 11 | "github.com/ouqiang/gocron/internal/modules/rpc/client" 12 | "github.com/ouqiang/gocron/internal/modules/rpc/grpcpool" 13 | "github.com/ouqiang/gocron/internal/modules/rpc/proto" 14 | "github.com/ouqiang/gocron/internal/modules/utils" 15 | "github.com/ouqiang/gocron/internal/routers/base" 16 | "github.com/ouqiang/gocron/internal/service" 17 | macaron "gopkg.in/macaron.v1" 18 | ) 19 | 20 | const testConnectionCommand = "echo hello" 21 | const testConnectionTimeout = 5 22 | 23 | // Index 主机列表 24 | func Index(ctx *macaron.Context) string { 25 | hostModel := new(models.Host) 26 | queryParams := parseQueryParams(ctx) 27 | total, err := hostModel.Total(queryParams) 28 | if err != nil { 29 | logger.Error(err) 30 | } 31 | hosts, err := hostModel.List(queryParams) 32 | if err != nil { 33 | logger.Error(err) 34 | } 35 | 36 | jsonResp := utils.JsonResponse{} 37 | 38 | return jsonResp.Success(utils.SuccessContent, map[string]interface{}{ 39 | "total": total, 40 | "data": hosts, 41 | }) 42 | } 43 | 44 | // All 获取所有主机 45 | func All(ctx *macaron.Context) string { 46 | hostModel := new(models.Host) 47 | hostModel.PageSize = -1 48 | hosts, err := hostModel.List(models.CommonMap{}) 49 | if err != nil { 50 | logger.Error(err) 51 | } 52 | 53 | jsonResp := utils.JsonResponse{} 54 | 55 | return jsonResp.Success(utils.SuccessContent, hosts) 56 | } 57 | 58 | // Detail 主机详情 59 | func Detail(ctx *macaron.Context) string { 60 | hostModel := new(models.Host) 61 | id := ctx.ParamsInt(":id") 62 | err := hostModel.Find(id) 63 | jsonResp := utils.JsonResponse{} 64 | if err != nil || hostModel.Id == 0 { 65 | logger.Errorf("获取主机详情失败#主机id-%d", id) 66 | return jsonResp.Success(utils.SuccessContent, nil) 67 | } 68 | 69 | return jsonResp.Success(utils.SuccessContent, hostModel) 70 | } 71 | 72 | type HostForm struct { 73 | Id int16 74 | Name string `binding:"Required;MaxSize(64)"` 75 | Alias string `binding:"Required;MaxSize(32)"` 76 | Port int `binding:"Required;Range(1-65535)"` 77 | Remark string 78 | } 79 | 80 | // Error 表单验证错误处理 81 | func (f HostForm) Error(ctx *macaron.Context, errs binding.Errors) { 82 | if len(errs) == 0 { 83 | return 84 | } 85 | json := utils.JsonResponse{} 86 | content := json.CommonFailure("表单验证失败, 请检测输入") 87 | ctx.Write([]byte(content)) 88 | } 89 | 90 | // Store 保存、修改主机信息 91 | func Store(ctx *macaron.Context, form HostForm) string { 92 | json := utils.JsonResponse{} 93 | hostModel := new(models.Host) 94 | id := form.Id 95 | nameExist, err := hostModel.NameExists(form.Name, form.Id) 96 | if err != nil { 97 | return json.CommonFailure("操作失败", err) 98 | } 99 | if nameExist { 100 | return json.CommonFailure("主机名已存在") 101 | } 102 | 103 | hostModel.Name = strings.TrimSpace(form.Name) 104 | hostModel.Alias = strings.TrimSpace(form.Alias) 105 | hostModel.Port = form.Port 106 | hostModel.Remark = strings.TrimSpace(form.Remark) 107 | isCreate := false 108 | oldHostModel := new(models.Host) 109 | err = oldHostModel.Find(int(id)) 110 | if err != nil { 111 | return json.CommonFailure("主机不存在") 112 | } 113 | 114 | if id > 0 { 115 | _, err = hostModel.UpdateBean(id) 116 | } else { 117 | isCreate = true 118 | id, err = hostModel.Create() 119 | } 120 | if err != nil { 121 | return json.CommonFailure("保存失败", err) 122 | } 123 | 124 | if !isCreate { 125 | oldAddr := fmt.Sprintf("%s:%d", oldHostModel.Name, oldHostModel.Port) 126 | newAddr := fmt.Sprintf("%s:%d", hostModel.Name, hostModel.Port) 127 | if oldAddr != newAddr { 128 | grpcpool.Pool.Release(oldAddr) 129 | } 130 | 131 | taskModel := new(models.Task) 132 | tasks, err := taskModel.ActiveListByHostId(id) 133 | if err != nil { 134 | return json.CommonFailure("刷新任务主机信息失败", err) 135 | } 136 | service.ServiceTask.BatchAdd(tasks) 137 | } 138 | 139 | return json.Success("保存成功", nil) 140 | } 141 | 142 | // Remove 删除主机 143 | func Remove(ctx *macaron.Context) string { 144 | id, err := strconv.Atoi(ctx.Params(":id")) 145 | json := utils.JsonResponse{} 146 | if err != nil { 147 | return json.CommonFailure("参数错误", err) 148 | } 149 | taskHostModel := new(models.TaskHost) 150 | exist, err := taskHostModel.HostIdExist(int16(id)) 151 | if err != nil { 152 | return json.CommonFailure("操作失败", err) 153 | } 154 | if exist { 155 | return json.CommonFailure("有任务引用此主机,不能删除") 156 | } 157 | 158 | hostModel := new(models.Host) 159 | err = hostModel.Find(int(id)) 160 | if err != nil { 161 | return json.CommonFailure("主机不存在") 162 | } 163 | 164 | _, err = hostModel.Delete(id) 165 | if err != nil { 166 | return json.CommonFailure("操作失败", err) 167 | } 168 | 169 | addr := fmt.Sprintf("%s:%d", hostModel.Name, hostModel.Port) 170 | grpcpool.Pool.Release(addr) 171 | 172 | return json.Success("操作成功", nil) 173 | } 174 | 175 | // Ping 测试主机是否可连接 176 | func Ping(ctx *macaron.Context) string { 177 | id := ctx.ParamsInt(":id") 178 | hostModel := new(models.Host) 179 | err := hostModel.Find(id) 180 | json := utils.JsonResponse{} 181 | if err != nil || hostModel.Id <= 0 { 182 | return json.CommonFailure("主机不存在", err) 183 | } 184 | 185 | taskReq := &rpc.TaskRequest{} 186 | taskReq.Command = testConnectionCommand 187 | taskReq.Timeout = testConnectionTimeout 188 | output, err := client.Exec(hostModel.Name, hostModel.Port, taskReq) 189 | if err != nil { 190 | return json.CommonFailure("连接失败-"+err.Error()+" "+output, err) 191 | } 192 | 193 | return json.Success("连接成功", nil) 194 | } 195 | 196 | // 解析查询参数 197 | func parseQueryParams(ctx *macaron.Context) models.CommonMap { 198 | var params = models.CommonMap{} 199 | params["Id"] = ctx.QueryInt("id") 200 | params["Name"] = ctx.QueryTrim("name") 201 | base.ParsePageAndPageSize(ctx, params) 202 | 203 | return params 204 | } 205 | -------------------------------------------------------------------------------- /internal/routers/install/install.go: -------------------------------------------------------------------------------- 1 | package install 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | 8 | macaron "gopkg.in/macaron.v1" 9 | 10 | "github.com/go-macaron/binding" 11 | "github.com/go-sql-driver/mysql" 12 | "github.com/lib/pq" 13 | "github.com/ouqiang/gocron/internal/models" 14 | "github.com/ouqiang/gocron/internal/modules/app" 15 | "github.com/ouqiang/gocron/internal/modules/setting" 16 | "github.com/ouqiang/gocron/internal/modules/utils" 17 | "github.com/ouqiang/gocron/internal/service" 18 | ) 19 | 20 | // 系统安装 21 | 22 | type InstallForm struct { 23 | DbType string `binding:"In(mysql,postgres)"` 24 | DbHost string `binding:"Required;MaxSize(50)"` 25 | DbPort int `binding:"Required;Range(1,65535)"` 26 | DbUsername string `binding:"Required;MaxSize(50)"` 27 | DbPassword string `binding:"Required;MaxSize(30)"` 28 | DbName string `binding:"Required;MaxSize(50)"` 29 | DbTablePrefix string `binding:"MaxSize(20)"` 30 | AdminUsername string `binding:"Required;MinSize(3)"` 31 | AdminPassword string `binding:"Required;MinSize(6)"` 32 | ConfirmAdminPassword string `binding:"Required;MinSize(6)"` 33 | AdminEmail string `binding:"Required;Email;MaxSize(50)"` 34 | } 35 | 36 | func (f InstallForm) Error(ctx *macaron.Context, errs binding.Errors) { 37 | if len(errs) == 0 { 38 | return 39 | } 40 | json := utils.JsonResponse{} 41 | content := json.CommonFailure("表单验证失败, 请检测输入") 42 | ctx.Write([]byte(content)) 43 | } 44 | 45 | // 安装 46 | func Store(ctx *macaron.Context, form InstallForm) string { 47 | json := utils.JsonResponse{} 48 | if app.Installed { 49 | return json.CommonFailure("系统已安装!") 50 | } 51 | if form.AdminPassword != form.ConfirmAdminPassword { 52 | return json.CommonFailure("两次输入密码不匹配") 53 | } 54 | err := testDbConnection(form) 55 | if err != nil { 56 | return json.CommonFailure(err.Error()) 57 | } 58 | // 写入数据库配置 59 | err = writeConfig(form) 60 | if err != nil { 61 | return json.CommonFailure("数据库配置写入文件失败", err) 62 | } 63 | 64 | appConfig, err := setting.Read(app.AppConfig) 65 | if err != nil { 66 | return json.CommonFailure("读取应用配置失败", err) 67 | } 68 | app.Setting = appConfig 69 | 70 | models.Db = models.CreateDb() 71 | // 创建数据库表 72 | migration := new(models.Migration) 73 | err = migration.Install(form.DbName) 74 | if err != nil { 75 | return json.CommonFailure(fmt.Sprintf("创建数据库表失败-%s", err.Error()), err) 76 | } 77 | 78 | // 创建管理员账号 79 | err = createAdminUser(form) 80 | if err != nil { 81 | return json.CommonFailure("创建管理员账号失败", err) 82 | } 83 | 84 | // 创建安装锁 85 | err = app.CreateInstallLock() 86 | if err != nil { 87 | return json.CommonFailure("创建文件安装锁失败", err) 88 | } 89 | 90 | // 更新版本号文件 91 | app.UpdateVersionFile() 92 | 93 | app.Installed = true 94 | // 初始化定时任务 95 | service.ServiceTask.Initialize() 96 | 97 | return json.Success("安装成功", nil) 98 | } 99 | 100 | // 配置写入文件 101 | func writeConfig(form InstallForm) error { 102 | dbConfig := []string{ 103 | "db.engine", form.DbType, 104 | "db.host", form.DbHost, 105 | "db.port", strconv.Itoa(form.DbPort), 106 | "db.user", form.DbUsername, 107 | "db.password", form.DbPassword, 108 | "db.database", form.DbName, 109 | "db.prefix", form.DbTablePrefix, 110 | "db.charset", "utf8", 111 | "db.max.idle.conns", "5", 112 | "db.max.open.conns", "100", 113 | "allow_ips", "", 114 | "app.name", "定时任务管理系统", // 应用名称 115 | "api.key", "", 116 | "api.secret", "", 117 | "enable_tls", "false", 118 | "concurrency.queue", "500", 119 | "auth_secret", utils.RandAuthToken(), 120 | "ca_file", "", 121 | "cert_file", "", 122 | "key_file", "", 123 | } 124 | 125 | return setting.Write(dbConfig, app.AppConfig) 126 | } 127 | 128 | // 创建管理员账号 129 | func createAdminUser(form InstallForm) error { 130 | user := new(models.User) 131 | user.Name = form.AdminUsername 132 | user.Password = form.AdminPassword 133 | user.Email = form.AdminEmail 134 | user.IsAdmin = 1 135 | _, err := user.Create() 136 | 137 | return err 138 | } 139 | 140 | // 测试数据库连接 141 | func testDbConnection(form InstallForm) error { 142 | var s setting.Setting 143 | s.Db.Engine = form.DbType 144 | s.Db.Host = form.DbHost 145 | s.Db.Port = form.DbPort 146 | s.Db.User = form.DbUsername 147 | s.Db.Password = form.DbPassword 148 | s.Db.Database = form.DbName 149 | s.Db.Charset = "utf8" 150 | db, err := models.CreateTmpDb(&s) 151 | if err != nil { 152 | return err 153 | } 154 | defer db.Close() 155 | err = db.Ping() 156 | if s.Db.Engine == "postgres" && err != nil { 157 | pgError, ok := err.(*pq.Error) 158 | if ok && pgError.Code == "3D000" { 159 | err = errors.New("数据库不存在") 160 | } 161 | return err 162 | } 163 | 164 | if s.Db.Engine == "mysql" && err != nil { 165 | mysqlError, ok := err.(*mysql.MySQLError) 166 | if ok && mysqlError.Number == 1049 { 167 | err = errors.New("数据库不存在") 168 | } 169 | return err 170 | } 171 | 172 | return err 173 | 174 | } 175 | -------------------------------------------------------------------------------- /internal/routers/loginlog/login_log.go: -------------------------------------------------------------------------------- 1 | package loginlog 2 | 3 | import ( 4 | "github.com/ouqiang/gocron/internal/models" 5 | "github.com/ouqiang/gocron/internal/modules/logger" 6 | "github.com/ouqiang/gocron/internal/modules/utils" 7 | "github.com/ouqiang/gocron/internal/routers/base" 8 | macaron "gopkg.in/macaron.v1" 9 | ) 10 | 11 | func Index(ctx *macaron.Context) string { 12 | loginLogModel := new(models.LoginLog) 13 | params := models.CommonMap{} 14 | base.ParsePageAndPageSize(ctx, params) 15 | total, err := loginLogModel.Total() 16 | if err != nil { 17 | logger.Error(err) 18 | } 19 | loginLogs, err := loginLogModel.List(params) 20 | if err != nil { 21 | logger.Error(err) 22 | } 23 | 24 | jsonResp := utils.JsonResponse{} 25 | 26 | return jsonResp.Success(utils.SuccessContent, map[string]interface{}{ 27 | "total": total, 28 | "data": loginLogs, 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /internal/routers/manage/manage.go: -------------------------------------------------------------------------------- 1 | package manage 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/ouqiang/gocron/internal/models" 7 | "github.com/ouqiang/gocron/internal/modules/logger" 8 | "github.com/ouqiang/gocron/internal/modules/utils" 9 | "gopkg.in/macaron.v1" 10 | ) 11 | 12 | func Slack(ctx *macaron.Context) string { 13 | settingModel := new(models.Setting) 14 | slack, err := settingModel.Slack() 15 | jsonResp := utils.JsonResponse{} 16 | if err != nil { 17 | logger.Error(err) 18 | return jsonResp.Success(utils.SuccessContent, nil) 19 | 20 | } 21 | 22 | return jsonResp.Success(utils.SuccessContent, slack) 23 | } 24 | 25 | func UpdateSlack(ctx *macaron.Context) string { 26 | url := ctx.QueryTrim("url") 27 | template := ctx.QueryTrim("template") 28 | settingModel := new(models.Setting) 29 | err := settingModel.UpdateSlack(url, template) 30 | 31 | return utils.JsonResponseByErr(err) 32 | } 33 | 34 | func CreateSlackChannel(ctx *macaron.Context) string { 35 | channel := ctx.QueryTrim("channel") 36 | settingModel := new(models.Setting) 37 | if settingModel.IsChannelExist(channel) { 38 | jsonResp := utils.JsonResponse{} 39 | 40 | return jsonResp.CommonFailure("Channel已存在") 41 | } 42 | _, err := settingModel.CreateChannel(channel) 43 | 44 | return utils.JsonResponseByErr(err) 45 | } 46 | 47 | func RemoveSlackChannel(ctx *macaron.Context) string { 48 | id := ctx.ParamsInt(":id") 49 | settingModel := new(models.Setting) 50 | _, err := settingModel.RemoveChannel(id) 51 | 52 | return utils.JsonResponseByErr(err) 53 | } 54 | 55 | // endregion 56 | 57 | // region 邮件 58 | func Mail(ctx *macaron.Context) string { 59 | settingModel := new(models.Setting) 60 | mail, err := settingModel.Mail() 61 | jsonResp := utils.JsonResponse{} 62 | if err != nil { 63 | logger.Error(err) 64 | return jsonResp.Success(utils.SuccessContent, nil) 65 | } 66 | 67 | return jsonResp.Success("", mail) 68 | } 69 | 70 | type MailServerForm struct { 71 | Host string `binding:"Required;MaxSize(100)"` 72 | Port int `binding:"Required;Range(1-65535)"` 73 | User string `binding:"Required;MaxSize(64);Email"` 74 | Password string `binding:"Required;MaxSize(64)"` 75 | } 76 | 77 | func UpdateMail(ctx *macaron.Context, form MailServerForm) string { 78 | jsonByte, _ := json.Marshal(form) 79 | settingModel := new(models.Setting) 80 | 81 | template := ctx.QueryTrim("template") 82 | err := settingModel.UpdateMail(string(jsonByte), template) 83 | 84 | return utils.JsonResponseByErr(err) 85 | } 86 | 87 | func CreateMailUser(ctx *macaron.Context) string { 88 | username := ctx.QueryTrim("username") 89 | email := ctx.QueryTrim("email") 90 | settingModel := new(models.Setting) 91 | if username == "" || email == "" { 92 | jsonResp := utils.JsonResponse{} 93 | 94 | return jsonResp.CommonFailure("用户名、邮箱均不能为空") 95 | } 96 | _, err := settingModel.CreateMailUser(username, email) 97 | 98 | return utils.JsonResponseByErr(err) 99 | } 100 | 101 | func RemoveMailUser(ctx *macaron.Context) string { 102 | id := ctx.ParamsInt(":id") 103 | settingModel := new(models.Setting) 104 | _, err := settingModel.RemoveMailUser(id) 105 | 106 | return utils.JsonResponseByErr(err) 107 | } 108 | 109 | func WebHook(ctx *macaron.Context) string { 110 | settingModel := new(models.Setting) 111 | webHook, err := settingModel.Webhook() 112 | jsonResp := utils.JsonResponse{} 113 | if err != nil { 114 | logger.Error(err) 115 | return jsonResp.Success(utils.SuccessContent, nil) 116 | } 117 | 118 | return jsonResp.Success("", webHook) 119 | } 120 | 121 | func UpdateWebHook(ctx *macaron.Context) string { 122 | url := ctx.QueryTrim("url") 123 | template := ctx.QueryTrim("template") 124 | settingModel := new(models.Setting) 125 | err := settingModel.UpdateWebHook(url, template) 126 | 127 | return utils.JsonResponseByErr(err) 128 | } 129 | 130 | // endregion 131 | -------------------------------------------------------------------------------- /internal/routers/tasklog/task_log.go: -------------------------------------------------------------------------------- 1 | package tasklog 2 | 3 | // 任务日志 4 | 5 | import ( 6 | "github.com/ouqiang/gocron/internal/models" 7 | "github.com/ouqiang/gocron/internal/modules/logger" 8 | "github.com/ouqiang/gocron/internal/modules/utils" 9 | "github.com/ouqiang/gocron/internal/routers/base" 10 | "github.com/ouqiang/gocron/internal/service" 11 | "gopkg.in/macaron.v1" 12 | ) 13 | 14 | func Index(ctx *macaron.Context) string { 15 | logModel := new(models.TaskLog) 16 | queryParams := parseQueryParams(ctx) 17 | total, err := logModel.Total(queryParams) 18 | if err != nil { 19 | logger.Error(err) 20 | } 21 | logs, err := logModel.List(queryParams) 22 | if err != nil { 23 | logger.Error(err) 24 | } 25 | jsonResp := utils.JsonResponse{} 26 | 27 | return jsonResp.Success(utils.SuccessContent, map[string]interface{}{ 28 | "total": total, 29 | "data": logs, 30 | }) 31 | } 32 | 33 | // 清空日志 34 | func Clear(ctx *macaron.Context) string { 35 | taskLogModel := new(models.TaskLog) 36 | _, err := taskLogModel.Clear() 37 | json := utils.JsonResponse{} 38 | if err != nil { 39 | return json.CommonFailure(utils.FailureContent) 40 | } 41 | 42 | return json.Success(utils.SuccessContent, nil) 43 | } 44 | 45 | // 停止运行中的任务 46 | func Stop(ctx *macaron.Context) string { 47 | id := ctx.QueryInt64("id") 48 | taskId := ctx.QueryInt("task_id") 49 | taskModel := new(models.Task) 50 | task, err := taskModel.Detail(taskId) 51 | json := utils.JsonResponse{} 52 | if err != nil { 53 | return json.CommonFailure("获取任务信息失败#"+err.Error(), err) 54 | } 55 | if task.Protocol != models.TaskRPC { 56 | return json.CommonFailure("仅支持SHELL任务手动停止") 57 | } 58 | if len(task.Hosts) == 0 { 59 | return json.CommonFailure("任务节点列表为空") 60 | } 61 | for _, host := range task.Hosts { 62 | service.ServiceTask.Stop(host.Name, host.Port, id) 63 | 64 | } 65 | 66 | return json.Success("已执行停止操作, 请等待任务退出", nil) 67 | } 68 | 69 | // 删除N个月前的日志 70 | func Remove(ctx *macaron.Context) string { 71 | month := ctx.ParamsInt(":id") 72 | json := utils.JsonResponse{} 73 | if month < 1 || month > 12 { 74 | return json.CommonFailure("参数取值范围1-12") 75 | } 76 | taskLogModel := new(models.TaskLog) 77 | _, err := taskLogModel.Remove(month) 78 | if err != nil { 79 | return json.CommonFailure("删除失败", err) 80 | } 81 | 82 | return json.Success("删除成功", nil) 83 | } 84 | 85 | // 解析查询参数 86 | func parseQueryParams(ctx *macaron.Context) models.CommonMap { 87 | var params models.CommonMap = models.CommonMap{} 88 | params["TaskId"] = ctx.QueryInt("task_id") 89 | params["Protocol"] = ctx.QueryInt("protocol") 90 | status := ctx.QueryInt("status") 91 | if status >= 0 { 92 | status -= 1 93 | } 94 | params["Status"] = status 95 | base.ParsePageAndPageSize(ctx, params) 96 | 97 | return params 98 | } 99 | -------------------------------------------------------------------------------- /k8s-deploy/Dockerfile-agent: -------------------------------------------------------------------------------- 1 | FROM alpine:3.7 2 | 3 | ENV GOCRON_AGENT_VERSION=v1.5 4 | 5 | RUN apk add --no-cache ca-certificates tzdata bash \ 6 | && mkdir -p /app \ 7 | && wget -P /tmp https://github.com/ouqiang/gocron/releases/download/${GOCRON_AGENT_VERSION}/gocron-node-${GOCRON_AGENT_VERSION}-linux-amd64.tar.gz \ 8 | && cd /tmp \ 9 | && tar zvxf gocron-node-${GOCRON_AGENT_VERSION}-linux-amd64.tar.gz \ 10 | && mv /tmp/gocron-node-linux-amd64/gocron-node /app \ 11 | && rm -rf /tmp/* \ 12 | && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime 13 | 14 | WORKDIR /app 15 | EXPOSE 5921 16 | 17 | ENTRYPOINT ["/app/gocron-node", "-allow-root"] 18 | -------------------------------------------------------------------------------- /k8s-deploy/gocron-agent.yml: -------------------------------------------------------------------------------- 1 | apiVersion: extensions/v1beta1 2 | kind: DaemonSet 3 | metadata: 4 | name: gocron-agent 5 | namespace: kube-system 6 | spec: 7 | selector: 8 | matchLabels: 9 | daemon: gocron-agent 10 | template: 11 | metadata: 12 | labels: 13 | daemon: gocron-agent 14 | name: gocron-agent 15 | spec: 16 | containers: 17 | - name: gocron-agent 18 | image: crontab-agent:0.5 19 | imagePullPolicy: IfNotPresent 20 | ports: 21 | - containerPort: 5921 22 | hostPort: 5921 23 | name: gocron-agent 24 | protocol: TCP 25 | resources: {} 26 | terminationMessagePath: /dev/termination-log 27 | terminationMessagePolicy: File 28 | volumeMounts: 29 | - mountPath: /var/log 30 | name: syslog 31 | hostNetwork: true 32 | hostPID: true 33 | restartPolicy: Always 34 | volumes: 35 | - hostPath: 36 | path: /var/log 37 | type: Directory 38 | name: syslog -------------------------------------------------------------------------------- /k8s-deploy/gocron-st.yml: -------------------------------------------------------------------------------- 1 | # 改为用StatefulSet部署,把配置目录挂载到外置存储,以防container down后重启 2 | # 如果使用历史数据库,需要先配置安装,然后在conf目录下创建install.lock文件,最后重启进程即可 3 | # 4 | --- 5 | apiVersion: v1 6 | kind: Service 7 | metadata: 8 | name: gocron 9 | labels: 10 | app: gocron 11 | spec: 12 | ports: 13 | - port: 5920 14 | name: gocron 15 | type: NodePort 16 | selector: 17 | app: gocron 18 | --- 19 | apiVersion: apps/v1beta1 20 | kind: StatefulSet 21 | metadata: 22 | name: gocron 23 | spec: 24 | serviceName: "gocron" 25 | replicas: 1 26 | template: 27 | metadata: 28 | labels: 29 | app: gocron 30 | spec: 31 | securityContext: 32 | fsGroup: 1000 33 | terminationGracePeriodSeconds: 10 34 | containers: 35 | - name: gocron 36 | image: ouqg/gocron:latest 37 | ports: 38 | - containerPort: 5920 39 | name: gocron 40 | volumeMounts: 41 | - name: gocron-vol 42 | mountPath: /app/conf 43 | volumeClaimTemplates: 44 | - metadata: 45 | name: gocron-vol 46 | spec: 47 | storageClassName: rbd 48 | accessModes: [ "ReadWriteOnce" ] 49 | resources: 50 | requests: 51 | storage: 100Mi -------------------------------------------------------------------------------- /k8s-deploy/mysql-st.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: mysql 6 | #namespace: storage 7 | labels: 8 | app: mysql 9 | spec: 10 | ports: 11 | - port: 3306 12 | name: mysql 13 | clusterIP: None 14 | selector: 15 | app: mysql 16 | --- 17 | apiVersion: apps/v1beta1 18 | kind: StatefulSet 19 | metadata: 20 | name: mysql 21 | #namespace: storage 22 | spec: 23 | serviceName: "mysql" 24 | replicas: 1 25 | template: 26 | metadata: 27 | labels: 28 | app: mysql 29 | spec: 30 | terminationGracePeriodSeconds: 10 31 | containers: 32 | - name: mysql 33 | image: mysql:5.6 34 | env: 35 | - name: MYSQL_ROOT_PASSWORD 36 | value: password 37 | - name: MYSQL_DATABASE 38 | value: crontab 39 | ports: 40 | - containerPort: 3306 41 | name: mysql 42 | volumeMounts: 43 | - name: mysql-vol 44 | mountPath: /var/lib/mysql 45 | volumeClaimTemplates: 46 | - metadata: 47 | name: mysql-vol 48 | spec: 49 | storageClassName: rbd # 需要storageClassName 50 | accessModes: [ "ReadWriteOnce" ] 51 | resources: 52 | requests: 53 | storage: 100Gi -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | GO111MODULE=on 2 | 3 | .PHONY: build 4 | build: gocron node 5 | 6 | .PHONY: build-race 7 | build-race: enable-race build 8 | 9 | .PHONY: run 10 | run: build kill 11 | ./bin/gocron-node & 12 | ./bin/gocron web -e dev 13 | 14 | .PHONY: run-race 15 | run-race: enable-race run 16 | 17 | .PHONY: kill 18 | kill: 19 | -killall gocron-node 20 | 21 | .PHONY: gocron 22 | gocron: 23 | go build $(RACE) -o bin/gocron ./cmd/gocron 24 | 25 | .PHONY: node 26 | node: 27 | go build $(RACE) -o bin/gocron-node ./cmd/node 28 | 29 | .PHONY: test 30 | test: 31 | go test $(RACE) ./... 32 | 33 | .PHONY: test-race 34 | test-race: enable-race test 35 | 36 | .PHONY: enable-race 37 | enable-race: 38 | $(eval RACE = -race) 39 | 40 | .PHONY: package 41 | package: build-vue statik 42 | bash ./package.sh 43 | 44 | .PHONY: package-all 45 | package-all: build-vue statik 46 | bash ./package.sh -p 'linux darwin windows' 47 | 48 | .PHONY: build-vue 49 | build-vue: 50 | cd web/vue && yarn run build 51 | cp -r web/vue/dist/* web/public/ 52 | 53 | .PHONY: install-vue 54 | install-vue: 55 | cd web/vue && yarn install 56 | 57 | .PHONY: run-vue 58 | run-vue: 59 | cd web/vue && yarn run dev 60 | 61 | .PHONY: statik 62 | statik: 63 | go get github.com/rakyll/statik 64 | go generate ./... 65 | 66 | .PHONY: lint 67 | golangci-lint run 68 | 69 | .PHONY: clean 70 | clean: 71 | rm bin/gocron 72 | rm bin/gocron-node 73 | -------------------------------------------------------------------------------- /package.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # 生成压缩包 xx.tar.gz或xx.zip 4 | # 使用 ./package.sh -a amd664 -p linux -v v2.0.0 5 | 6 | # 任何命令返回非0值退出 7 | set -o errexit 8 | # 使用未定义的变量退出 9 | set -o nounset 10 | # 管道中任一命令执行失败退出 11 | set -o pipefail 12 | 13 | eval $(go env) 14 | 15 | # 二进制文件名 16 | BINARY_NAME='' 17 | # main函数所在文件 18 | MAIN_FILE="" 19 | 20 | # 提取git最新tag作为应用版本 21 | VERSION='' 22 | # 最新git commit id 23 | GIT_COMMIT_ID='' 24 | 25 | # 外部输入的系统 26 | INPUT_OS=() 27 | # 外部输入的架构 28 | INPUT_ARCH=() 29 | # 未指定OS,默认值 30 | DEFAULT_OS=${GOHOSTOS} 31 | # 未指定ARCH,默认值 32 | DEFAULT_ARCH=${GOHOSTARCH} 33 | # 支持的系统 34 | SUPPORT_OS=(linux darwin windows) 35 | # 支持的架构 36 | SUPPORT_ARCH=(386 amd64) 37 | 38 | # 编译参数 39 | LDFLAGS='' 40 | # 需要打包的文件 41 | INCLUDE_FILE=() 42 | # 打包文件生成目录 43 | PACKAGE_DIR='' 44 | # 编译文件生成目录 45 | BUILD_DIR='' 46 | 47 | # 获取git 最新tag name 48 | git_latest_tag() { 49 | local COMMIT_ID="" 50 | local TAG_NAME="" 51 | COMMIT_ID=`git rev-list --tags --max-count=1` 52 | TAG_NAME=`git describe --tags "${COMMIT_ID}"` 53 | 54 | echo ${TAG_NAME} 55 | } 56 | 57 | # 获取git 最新commit id 58 | git_latest_commit() { 59 | echo "$(git rev-parse --short HEAD)" 60 | } 61 | 62 | # 打印信息 63 | print_message() { 64 | echo "$1" 65 | } 66 | 67 | # 打印信息后推出 68 | print_message_and_exit() { 69 | if [[ -n $1 ]]; then 70 | print_message "$1" 71 | fi 72 | exit 1 73 | } 74 | 75 | # 设置系统、CPU架构 76 | set_os_arch() { 77 | if [[ ${#INPUT_OS[@]} = 0 ]];then 78 | INPUT_OS=("${DEFAULT_OS}") 79 | fi 80 | 81 | if [[ ${#INPUT_ARCH[@]} = 0 ]];then 82 | INPUT_ARCH=("${DEFAULT_ARCH}") 83 | fi 84 | 85 | for OS in "${INPUT_OS[@]}"; do 86 | if [[ ! "${SUPPORT_OS[*]}" =~ ${OS} ]]; then 87 | print_message_and_exit "不支持的系统${OS}" 88 | fi 89 | done 90 | 91 | for ARCH in "${INPUT_ARCH[@]}";do 92 | if [[ ! "${SUPPORT_ARCH[*]}" =~ ${ARCH} ]]; then 93 | print_message_and_exit "不支持的CPU架构${ARCH}" 94 | fi 95 | done 96 | } 97 | 98 | # 初始化 99 | init() { 100 | set_os_arch 101 | 102 | if [[ -z "${VERSION}" ]];then 103 | VERSION=`git_latest_tag` 104 | fi 105 | GIT_COMMIT_ID=`git_latest_commit` 106 | LDFLAGS="-w -X 'main.AppVersion=${VERSION}' -X 'main.BuildDate=`date '+%Y-%m-%d %H:%M:%S'`' -X 'main.GitCommit=${GIT_COMMIT_ID}'" 107 | 108 | PACKAGE_DIR=${BINARY_NAME}-package 109 | BUILD_DIR=${BINARY_NAME}-build 110 | 111 | if [[ -d ${BUILD_DIR} ]];then 112 | rm -rf ${BUILD_DIR} 113 | fi 114 | if [[ -d ${PACKAGE_DIR} ]];then 115 | rm -rf ${PACKAGE_DIR} 116 | fi 117 | 118 | mkdir -p ${BUILD_DIR} 119 | mkdir -p ${PACKAGE_DIR} 120 | } 121 | 122 | # 编译 123 | build() { 124 | local FILENAME='' 125 | for OS in "${INPUT_OS[@]}";do 126 | for ARCH in "${INPUT_ARCH[@]}";do 127 | if [[ "${OS}" = "windows" ]];then 128 | FILENAME=${BINARY_NAME}.exe 129 | else 130 | FILENAME=${BINARY_NAME} 131 | fi 132 | env CGO_ENABLED=0 GOOS=${OS} GOARCH=${ARCH} go build -ldflags "${LDFLAGS}" -o ${BUILD_DIR}/${BINARY_NAME}-${OS}-${ARCH}/${FILENAME} ${MAIN_FILE} 133 | done 134 | done 135 | } 136 | 137 | # 打包 138 | package_binary() { 139 | cd ${BUILD_DIR} 140 | 141 | for OS in "${INPUT_OS[@]}";do 142 | for ARCH in "${INPUT_ARCH[@]}";do 143 | package_file ${BINARY_NAME}-${OS}-${ARCH} 144 | if [[ "${OS}" = "windows" ]];then 145 | zip -rq ../${PACKAGE_DIR}/${BINARY_NAME}-${VERSION}-${OS}-${ARCH}.zip ${BINARY_NAME}-${OS}-${ARCH} 146 | else 147 | tar czf ../${PACKAGE_DIR}/${BINARY_NAME}-${VERSION}-${OS}-${ARCH}.tar.gz ${BINARY_NAME}-${OS}-${ARCH} 148 | fi 149 | done 150 | done 151 | 152 | cd ${OLDPWD} 153 | } 154 | 155 | # 打包文件 156 | package_file() { 157 | if [[ "${#INCLUDE_FILE[@]}" = "0" ]];then 158 | return 159 | fi 160 | for item in "${INCLUDE_FILE[@]}"; do 161 | cp -r ../${item} $1 162 | done 163 | } 164 | 165 | # 清理 166 | clean() { 167 | if [[ -d ${BUILD_DIR} ]];then 168 | rm -rf ${BUILD_DIR} 169 | fi 170 | } 171 | 172 | # 运行 173 | run() { 174 | init 175 | build 176 | package_binary 177 | clean 178 | } 179 | 180 | package_gocron() { 181 | BINARY_NAME='gocron' 182 | MAIN_FILE="./cmd/gocron/gocron.go" 183 | INCLUDE_FILE=() 184 | 185 | 186 | run 187 | } 188 | 189 | package_gocron_node() { 190 | BINARY_NAME='gocron-node' 191 | MAIN_FILE="./cmd/node/node.go" 192 | INCLUDE_FILE=() 193 | 194 | run 195 | } 196 | 197 | # p 平台 linux darwin windows 198 | # a 架构 386 amd64 199 | # v 版本号 默认取git最新tag 200 | while getopts "p:a:v:" OPT; 201 | do 202 | case ${OPT} in 203 | p) IPS=',' read -r -a INPUT_OS <<< "${OPTARG}" 204 | ;; 205 | a) IPS=',' read -r -a INPUT_ARCH <<< "${OPTARG}" 206 | ;; 207 | v) VERSION=$OPTARG 208 | ;; 209 | *) 210 | ;; 211 | esac 212 | done 213 | 214 | package_gocron 215 | package_gocron_node 216 | 217 | -------------------------------------------------------------------------------- /web/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | 3 | Disallow: / -------------------------------------------------------------------------------- /web/vue/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "modules": false, 5 | "targets": { 6 | "browsers": ["> 1%", "last 2 versions", "not ie <= 8"] 7 | } 8 | }], 9 | "stage-2" 10 | ], 11 | "plugins": ["transform-vue-jsx", "transform-runtime"] 12 | } 13 | -------------------------------------------------------------------------------- /web/vue/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /web/vue/.eslintignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | /config/ 3 | /dist/ 4 | /*.js 5 | -------------------------------------------------------------------------------- /web/vue/.eslintrc.js: -------------------------------------------------------------------------------- 1 | // https://eslint.org/docs/user-guide/configuring 2 | 3 | module.exports = { 4 | root: true, 5 | parserOptions: { 6 | parser: 'babel-eslint' 7 | }, 8 | env: { 9 | browser: true, 10 | }, 11 | extends: [ 12 | // https://github.com/vuejs/eslint-plugin-vue#priority-a-essential-error-prevention 13 | // consider switching to `plugin:vue/strongly-recommended` or `plugin:vue/recommended` for stricter rules. 14 | 'plugin:vue/essential', 15 | // https://github.com/standard/standard/blob/master/docs/RULES-en.md 16 | 'standard' 17 | ], 18 | // required to lint *.vue files 19 | plugins: [ 20 | 'vue' 21 | ], 22 | // add your custom rules here 23 | rules: { 24 | // allow async-await 25 | 'generator-star-spacing': 'off', 26 | // allow debugger during development 27 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off' 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /web/vue/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | /dist/ 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Editor directories and files 9 | .idea 10 | .vscode 11 | *.suo 12 | *.ntvs* 13 | *.njsproj 14 | *.sln 15 | -------------------------------------------------------------------------------- /web/vue/.postcssrc.js: -------------------------------------------------------------------------------- 1 | // https://github.com/michael-ciniawsky/postcss-load-config 2 | 3 | module.exports = { 4 | "plugins": { 5 | "postcss-import": {}, 6 | "postcss-url": {}, 7 | // to edit target browsers: use "browserslist" field in package.json 8 | "autoprefixer": {} 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /web/vue/README.md: -------------------------------------------------------------------------------- 1 | # gocron 2 | 3 | > 定时任务管理系统 4 | 5 | ## Build Setup 6 | 7 | ``` bash 8 | # install dependencies 9 | yarn install 10 | 11 | # serve with hot reload at localhost:8080 12 | yarn run dev 13 | 14 | # build for production with minification 15 | yarn run build 16 | 17 | # build for production and view the bundle analyzer report 18 | yarn run build --report 19 | ``` 20 | 21 | For a detailed explanation on how things work, check out the [guide](http://vuejs-templates.github.io/webpack/) and [docs for vue-loader](http://vuejs.github.io/vue-loader). 22 | -------------------------------------------------------------------------------- /web/vue/build/build.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | require('./check-versions')() 3 | 4 | process.env.NODE_ENV = 'production' 5 | 6 | const ora = require('ora') 7 | const rm = require('rimraf') 8 | const path = require('path') 9 | const chalk = require('chalk') 10 | const webpack = require('webpack') 11 | const config = require('../config') 12 | const webpackConfig = require('./webpack.prod.conf') 13 | 14 | const spinner = ora('building for production...') 15 | spinner.start() 16 | 17 | rm(path.join(config.build.assetsRoot, config.build.assetsSubDirectory), err => { 18 | if (err) throw err 19 | webpack(webpackConfig, (err, stats) => { 20 | spinner.stop() 21 | if (err) throw err 22 | process.stdout.write(stats.toString({ 23 | colors: true, 24 | modules: false, 25 | children: false, // If you are using ts-loader, setting this to true will make TypeScript errors show up during build. 26 | chunks: false, 27 | chunkModules: false 28 | }) + '\n\n') 29 | 30 | if (stats.hasErrors()) { 31 | console.log(chalk.red(' Build failed with errors.\n')) 32 | process.exit(1) 33 | } 34 | 35 | console.log(chalk.cyan(' Build complete.\n')) 36 | console.log(chalk.yellow( 37 | ' Tip: built files are meant to be served over an HTTP server.\n' + 38 | ' Opening index.html over file:// won\'t work.\n' 39 | )) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /web/vue/build/check-versions.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const chalk = require('chalk') 3 | const semver = require('semver') 4 | const packageConfig = require('../package.json') 5 | const shell = require('shelljs') 6 | 7 | function exec (cmd) { 8 | return require('child_process').execSync(cmd).toString().trim() 9 | } 10 | 11 | const versionRequirements = [ 12 | { 13 | name: 'node', 14 | currentVersion: semver.clean(process.version), 15 | versionRequirement: packageConfig.engines.node 16 | } 17 | ] 18 | 19 | if (shell.which('npm')) { 20 | versionRequirements.push({ 21 | name: 'npm', 22 | currentVersion: exec('npm --version'), 23 | versionRequirement: packageConfig.engines.npm 24 | }) 25 | } 26 | 27 | module.exports = function () { 28 | const warnings = [] 29 | 30 | for (let i = 0; i < versionRequirements.length; i++) { 31 | const mod = versionRequirements[i] 32 | 33 | if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) { 34 | warnings.push(mod.name + ': ' + 35 | chalk.red(mod.currentVersion) + ' should be ' + 36 | chalk.green(mod.versionRequirement) 37 | ) 38 | } 39 | } 40 | 41 | if (warnings.length) { 42 | console.log('') 43 | console.log(chalk.yellow('To use this template, you must update following to modules:')) 44 | console.log() 45 | 46 | for (let i = 0; i < warnings.length; i++) { 47 | const warning = warnings[i] 48 | console.log(' ' + warning) 49 | } 50 | 51 | console.log() 52 | process.exit(1) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /web/vue/build/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ouqiang/gocron/960fc988b11ab32a121e4448f600c5ea089962b4/web/vue/build/logo.png -------------------------------------------------------------------------------- /web/vue/build/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require('path') 3 | const config = require('../config') 4 | const ExtractTextPlugin = require('extract-text-webpack-plugin') 5 | const packageConfig = require('../package.json') 6 | 7 | exports.assetsPath = function (_path) { 8 | const assetsSubDirectory = process.env.NODE_ENV === 'production' 9 | ? config.build.assetsSubDirectory 10 | : config.dev.assetsSubDirectory 11 | 12 | return path.posix.join(assetsSubDirectory, _path) 13 | } 14 | 15 | exports.cssLoaders = function (options) { 16 | options = options || {} 17 | 18 | const cssLoader = { 19 | loader: 'css-loader', 20 | options: { 21 | sourceMap: options.sourceMap 22 | } 23 | } 24 | 25 | const postcssLoader = { 26 | loader: 'postcss-loader', 27 | options: { 28 | sourceMap: options.sourceMap 29 | } 30 | } 31 | 32 | // generate loader string to be used with extract text plugin 33 | function generateLoaders (loader, loaderOptions) { 34 | const loaders = options.usePostCSS ? [cssLoader, postcssLoader] : [cssLoader] 35 | 36 | if (loader) { 37 | loaders.push({ 38 | loader: loader + '-loader', 39 | options: Object.assign({}, loaderOptions, { 40 | sourceMap: options.sourceMap 41 | }) 42 | }) 43 | } 44 | 45 | // Extract CSS when that option is specified 46 | // (which is the case during production build) 47 | if (options.extract) { 48 | return ExtractTextPlugin.extract({ 49 | use: loaders, 50 | fallback: 'vue-style-loader' 51 | }) 52 | } else { 53 | return ['vue-style-loader'].concat(loaders) 54 | } 55 | } 56 | 57 | // https://vue-loader.vuejs.org/en/configurations/extract-css.html 58 | return { 59 | css: generateLoaders(), 60 | postcss: generateLoaders(), 61 | less: generateLoaders('less'), 62 | sass: generateLoaders('sass', { indentedSyntax: true }), 63 | scss: generateLoaders('sass'), 64 | stylus: generateLoaders('stylus'), 65 | styl: generateLoaders('stylus') 66 | } 67 | } 68 | 69 | // Generate loaders for standalone style files (outside of .vue) 70 | exports.styleLoaders = function (options) { 71 | const output = [] 72 | const loaders = exports.cssLoaders(options) 73 | 74 | for (const extension in loaders) { 75 | const loader = loaders[extension] 76 | output.push({ 77 | test: new RegExp('\\.' + extension + '$'), 78 | use: loader 79 | }) 80 | } 81 | 82 | return output 83 | } 84 | 85 | exports.createNotifierCallback = () => { 86 | const notifier = require('node-notifier') 87 | 88 | return (severity, errors) => { 89 | if (severity !== 'error') return 90 | 91 | const error = errors[0] 92 | const filename = error.file && error.file.split('!').pop() 93 | 94 | notifier.notify({ 95 | title: packageConfig.name, 96 | message: severity + ': ' + error.name, 97 | subtitle: filename || '', 98 | icon: path.join(__dirname, 'logo.png') 99 | }) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /web/vue/build/vue-loader.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const utils = require('./utils') 3 | const config = require('../config') 4 | const isProduction = process.env.NODE_ENV === 'production' 5 | const sourceMapEnabled = isProduction 6 | ? config.build.productionSourceMap 7 | : config.dev.cssSourceMap 8 | 9 | module.exports = { 10 | loaders: utils.cssLoaders({ 11 | sourceMap: sourceMapEnabled, 12 | extract: isProduction 13 | }), 14 | cssSourceMap: sourceMapEnabled, 15 | cacheBusting: config.dev.cacheBusting, 16 | transformToRequire: { 17 | video: ['src', 'poster'], 18 | source: 'src', 19 | img: 'src', 20 | image: 'xlink:href' 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /web/vue/build/webpack.base.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require('path') 3 | const utils = require('./utils') 4 | const config = require('../config') 5 | const vueLoaderConfig = require('./vue-loader.conf') 6 | 7 | function resolve (dir) { 8 | return path.join(__dirname, '..', dir) 9 | } 10 | 11 | const createLintingRule = () => ({ 12 | test: /\.(js|vue)$/, 13 | loader: 'eslint-loader', 14 | enforce: 'pre', 15 | include: [resolve('src'), resolve('test')], 16 | options: { 17 | formatter: require('eslint-friendly-formatter'), 18 | emitWarning: !config.dev.showEslintErrorsInOverlay 19 | } 20 | }) 21 | 22 | module.exports = { 23 | context: path.resolve(__dirname, '../'), 24 | entry: { 25 | app: './src/main.js' 26 | }, 27 | output: { 28 | path: config.build.assetsRoot, 29 | filename: '[name].js', 30 | publicPath: process.env.NODE_ENV === 'production' 31 | ? config.build.assetsPublicPath 32 | : config.dev.assetsPublicPath 33 | }, 34 | resolve: { 35 | extensions: ['.js', '.vue', '.json'], 36 | alias: { 37 | 'vue$': 'vue/dist/vue.esm.js', 38 | '@': resolve('src'), 39 | } 40 | }, 41 | module: { 42 | rules: [ 43 | ...(config.dev.useEslint ? [createLintingRule()] : []), 44 | { 45 | test: /\.vue$/, 46 | loader: 'vue-loader', 47 | options: vueLoaderConfig 48 | }, 49 | { 50 | test: /\.js$/, 51 | loader: 'babel-loader', 52 | include: [resolve('src'), resolve('test'), resolve('node_modules/webpack-dev-server/client')] 53 | }, 54 | { 55 | test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, 56 | loader: 'url-loader', 57 | options: { 58 | limit: 10000, 59 | name: utils.assetsPath('img/[name].[hash:7].[ext]') 60 | } 61 | }, 62 | { 63 | test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/, 64 | loader: 'url-loader', 65 | options: { 66 | limit: 10000, 67 | name: utils.assetsPath('media/[name].[hash:7].[ext]') 68 | } 69 | }, 70 | { 71 | test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, 72 | loader: 'url-loader', 73 | options: { 74 | limit: 10000, 75 | name: utils.assetsPath('fonts/[name].[hash:7].[ext]') 76 | } 77 | } 78 | ] 79 | }, 80 | node: { 81 | // prevent webpack from injecting useless setImmediate polyfill because Vue 82 | // source contains it (although only uses it if it's native). 83 | setImmediate: false, 84 | // prevent webpack from injecting mocks to Node native modules 85 | // that does not make sense for the client 86 | dgram: 'empty', 87 | fs: 'empty', 88 | net: 'empty', 89 | tls: 'empty', 90 | child_process: 'empty' 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /web/vue/build/webpack.dev.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const utils = require('./utils') 3 | const webpack = require('webpack') 4 | const config = require('../config') 5 | const merge = require('webpack-merge') 6 | const path = require('path') 7 | const baseWebpackConfig = require('./webpack.base.conf') 8 | const CopyWebpackPlugin = require('copy-webpack-plugin') 9 | const HtmlWebpackPlugin = require('html-webpack-plugin') 10 | const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin') 11 | const portfinder = require('portfinder') 12 | 13 | const HOST = process.env.HOST 14 | const PORT = process.env.PORT && Number(process.env.PORT) 15 | 16 | const devWebpackConfig = merge(baseWebpackConfig, { 17 | module: { 18 | rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap, usePostCSS: true }) 19 | }, 20 | // cheap-module-eval-source-map is faster for development 21 | devtool: config.dev.devtool, 22 | 23 | // these devServer options should be customized in /config/index.js 24 | devServer: { 25 | clientLogLevel: 'warning', 26 | historyApiFallback: { 27 | rewrites: [ 28 | { from: /.*/, to: path.posix.join(config.dev.assetsPublicPath, 'index.html') }, 29 | ], 30 | }, 31 | hot: true, 32 | contentBase: false, // since we use CopyWebpackPlugin. 33 | compress: true, 34 | host: HOST || config.dev.host, 35 | port: PORT || config.dev.port, 36 | open: config.dev.autoOpenBrowser, 37 | overlay: config.dev.errorOverlay 38 | ? { warnings: false, errors: true } 39 | : false, 40 | publicPath: config.dev.assetsPublicPath, 41 | proxy: config.dev.proxyTable, 42 | quiet: true, // necessary for FriendlyErrorsPlugin 43 | watchOptions: { 44 | poll: config.dev.poll, 45 | } 46 | }, 47 | plugins: [ 48 | new webpack.DefinePlugin({ 49 | 'process.env': require('../config/dev.env') 50 | }), 51 | new webpack.HotModuleReplacementPlugin(), 52 | new webpack.NamedModulesPlugin(), // HMR shows correct file names in console on update. 53 | new webpack.NoEmitOnErrorsPlugin(), 54 | // https://github.com/ampedandwired/html-webpack-plugin 55 | new HtmlWebpackPlugin({ 56 | filename: 'index.html', 57 | template: 'index.html', 58 | inject: true 59 | }), 60 | // copy custom static assets 61 | new CopyWebpackPlugin([ 62 | { 63 | from: path.resolve(__dirname, '../static'), 64 | to: config.dev.assetsSubDirectory, 65 | ignore: ['.*'] 66 | } 67 | ]) 68 | ] 69 | }) 70 | 71 | module.exports = new Promise((resolve, reject) => { 72 | portfinder.basePort = process.env.PORT || config.dev.port 73 | portfinder.getPort((err, port) => { 74 | if (err) { 75 | reject(err) 76 | } else { 77 | // publish the new Port, necessary for e2e tests 78 | process.env.PORT = port 79 | // add port to devServer config 80 | devWebpackConfig.devServer.port = port 81 | 82 | // Add FriendlyErrorsPlugin 83 | devWebpackConfig.plugins.push(new FriendlyErrorsPlugin({ 84 | compilationSuccessInfo: { 85 | messages: [`Your application is running here: http://${devWebpackConfig.devServer.host}:${port}`], 86 | }, 87 | onErrors: config.dev.notifyOnErrors 88 | ? utils.createNotifierCallback() 89 | : undefined 90 | })) 91 | 92 | resolve(devWebpackConfig) 93 | } 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /web/vue/build/webpack.prod.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require('path') 3 | const utils = require('./utils') 4 | const webpack = require('webpack') 5 | const config = require('../config') 6 | const merge = require('webpack-merge') 7 | const baseWebpackConfig = require('./webpack.base.conf') 8 | const CopyWebpackPlugin = require('copy-webpack-plugin') 9 | const HtmlWebpackPlugin = require('html-webpack-plugin') 10 | const ExtractTextPlugin = require('extract-text-webpack-plugin') 11 | const OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin') 12 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin') 13 | 14 | const env = require('../config/prod.env') 15 | 16 | const webpackConfig = merge(baseWebpackConfig, { 17 | module: { 18 | rules: utils.styleLoaders({ 19 | sourceMap: config.build.productionSourceMap, 20 | extract: true, 21 | usePostCSS: true 22 | }) 23 | }, 24 | devtool: config.build.productionSourceMap ? config.build.devtool : false, 25 | output: { 26 | path: config.build.assetsRoot, 27 | filename: utils.assetsPath('js/[name].[chunkhash].js'), 28 | chunkFilename: utils.assetsPath('js/[id].[chunkhash].js') 29 | }, 30 | plugins: [ 31 | // http://vuejs.github.io/vue-loader/en/workflow/production.html 32 | new webpack.DefinePlugin({ 33 | 'process.env': env 34 | }), 35 | new UglifyJsPlugin({ 36 | uglifyOptions: { 37 | compress: { 38 | warnings: false 39 | } 40 | }, 41 | sourceMap: config.build.productionSourceMap, 42 | parallel: true 43 | }), 44 | // extract css into its own file 45 | new ExtractTextPlugin({ 46 | filename: utils.assetsPath('css/[name].[contenthash].css'), 47 | // Setting the following option to `false` will not extract CSS from codesplit chunks. 48 | // Their CSS will instead be inserted dynamically with style-loader when the codesplit chunk has been loaded by webpack. 49 | // It's currently set to `true` because we are seeing that sourcemaps are included in the codesplit bundle as well when it's `false`, 50 | // increasing file size: https://github.com/vuejs-templates/webpack/issues/1110 51 | allChunks: true, 52 | }), 53 | // Compress extracted CSS. We are using this plugin so that possible 54 | // duplicated CSS from different components can be deduped. 55 | new OptimizeCSSPlugin({ 56 | cssProcessorOptions: config.build.productionSourceMap 57 | ? { safe: true, map: { inline: false } } 58 | : { safe: true } 59 | }), 60 | // generate dist index.html with correct asset hash for caching. 61 | // you can customize output by editing /index.html 62 | // see https://github.com/ampedandwired/html-webpack-plugin 63 | new HtmlWebpackPlugin({ 64 | filename: config.build.index, 65 | template: 'index.html', 66 | inject: true, 67 | minify: { 68 | removeComments: true, 69 | collapseWhitespace: true, 70 | removeAttributeQuotes: true 71 | // more options: 72 | // https://github.com/kangax/html-minifier#options-quick-reference 73 | }, 74 | // necessary to consistently work with multiple chunks via CommonsChunkPlugin 75 | chunksSortMode: 'dependency' 76 | }), 77 | // keep module.id stable when vendor modules does not change 78 | new webpack.HashedModuleIdsPlugin(), 79 | // enable scope hoisting 80 | new webpack.optimize.ModuleConcatenationPlugin(), 81 | // split vendor js into its own file 82 | new webpack.optimize.CommonsChunkPlugin({ 83 | name: 'vendor', 84 | minChunks (module) { 85 | // any required modules inside node_modules are extracted to vendor 86 | return ( 87 | module.resource && 88 | /\.js$/.test(module.resource) && 89 | module.resource.indexOf( 90 | path.join(__dirname, '../node_modules') 91 | ) === 0 92 | ) 93 | } 94 | }), 95 | // extract webpack runtime and module manifest to its own file in order to 96 | // prevent vendor hash from being updated whenever app bundle is updated 97 | new webpack.optimize.CommonsChunkPlugin({ 98 | name: 'manifest', 99 | minChunks: Infinity 100 | }), 101 | // This instance extracts shared chunks from code splitted chunks and bundles them 102 | // in a separate chunk, similar to the vendor chunk 103 | // see: https://webpack.js.org/plugins/commons-chunk-plugin/#extra-async-commons-chunk 104 | new webpack.optimize.CommonsChunkPlugin({ 105 | name: 'app', 106 | async: 'vendor-async', 107 | children: true, 108 | minChunks: 3 109 | }), 110 | 111 | // copy custom static assets 112 | new CopyWebpackPlugin([ 113 | { 114 | from: path.resolve(__dirname, '../static'), 115 | to: config.build.assetsSubDirectory, 116 | ignore: ['.*'] 117 | } 118 | ]) 119 | ] 120 | }) 121 | 122 | if (config.build.productionGzip) { 123 | const CompressionWebpackPlugin = require('compression-webpack-plugin') 124 | 125 | webpackConfig.plugins.push( 126 | new CompressionWebpackPlugin({ 127 | asset: '[path].gz[query]', 128 | algorithm: 'gzip', 129 | test: new RegExp( 130 | '\\.(' + 131 | config.build.productionGzipExtensions.join('|') + 132 | ')$' 133 | ), 134 | threshold: 10240, 135 | minRatio: 0.8 136 | }) 137 | ) 138 | } 139 | 140 | if (config.build.bundleAnalyzerReport) { 141 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin 142 | webpackConfig.plugins.push(new BundleAnalyzerPlugin()) 143 | } 144 | 145 | module.exports = webpackConfig 146 | -------------------------------------------------------------------------------- /web/vue/config/dev.env.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const merge = require('webpack-merge') 3 | const prodEnv = require('./prod.env') 4 | 5 | module.exports = merge(prodEnv, { 6 | NODE_ENV: '"development"' 7 | }) 8 | -------------------------------------------------------------------------------- /web/vue/config/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | // Template version: 1.3.1 3 | // see http://vuejs-templates.github.io/webpack for documentation. 4 | 5 | const path = require('path') 6 | 7 | module.exports = { 8 | dev: { 9 | 10 | // Paths 11 | assetsSubDirectory: 'static', 12 | assetsPublicPath: '/', 13 | proxyTable: { 14 | '/api': { 15 | target: 'http://localhost:5920', 16 | changeOrigin: true, 17 | pathRewrite: { 18 | '^/api': '/api' 19 | } 20 | } 21 | }, 22 | 23 | // Various Dev Server settings 24 | host: 'localhost', // can be overwritten by process.env.HOST 25 | port: 8080, // can be overwritten by process.env.PORT, if port is in use, a free one will be determined 26 | autoOpenBrowser: false, 27 | errorOverlay: true, 28 | notifyOnErrors: true, 29 | poll: false, // https://webpack.js.org/configuration/dev-server/#devserver-watchoptions- 30 | 31 | // Use Eslint Loader? 32 | // If true, your code will be linted during bundling and 33 | // linting errors and warnings will be shown in the console. 34 | useEslint: true, 35 | // If true, eslint errors and warnings will also be shown in the error overlay 36 | // in the browser. 37 | showEslintErrorsInOverlay: false, 38 | 39 | /** 40 | * Source Maps 41 | */ 42 | 43 | // https://webpack.js.org/configuration/devtool/#development 44 | devtool: 'cheap-module-eval-source-map', 45 | 46 | // If you have problems debugging vue-files in devtools, 47 | // set this to false - it *may* help 48 | // https://vue-loader.vuejs.org/en/options.html#cachebusting 49 | cacheBusting: true, 50 | 51 | cssSourceMap: true 52 | }, 53 | 54 | build: { 55 | // Template for index.html 56 | index: path.resolve(__dirname, '../dist/index.html'), 57 | 58 | // Paths 59 | assetsRoot: path.resolve(__dirname, '../dist'), 60 | assetsSubDirectory: 'static', 61 | assetsPublicPath: 'public/', 62 | 63 | /** 64 | * Source Maps 65 | */ 66 | 67 | productionSourceMap: false, 68 | // https://webpack.js.org/configuration/devtool/#production 69 | devtool: '#source-map', 70 | 71 | // Gzip off by default as many popular static hosts such as 72 | // Surge or Netlify already gzip all static assets for you. 73 | // Before setting to `true`, make sure to: 74 | // npm install --save-dev compression-webpack-plugin 75 | productionGzip: false, 76 | productionGzipExtensions: ['js', 'css'], 77 | 78 | // Run the build command with an extra argument to 79 | // View the bundle analyzer report after build finishes: 80 | // `npm run build --report` 81 | // Set to `true` or `false` to always turn it on or off 82 | bundleAnalyzerReport: process.env.npm_config_report 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /web/vue/config/prod.env.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | module.exports = { 3 | NODE_ENV: '"production"' 4 | } 5 | -------------------------------------------------------------------------------- /web/vue/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | gocron - 定时任务系统 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /web/vue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gocron", 3 | "version": "1.0.0", 4 | "description": "定时任务管理系统", 5 | "author": "ouqiang ", 6 | "private": true, 7 | "scripts": { 8 | "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js", 9 | "start": "npm run dev", 10 | "lint": "eslint --ext .js,.vue src", 11 | "build": "node build/build.js" 12 | }, 13 | "dependencies": { 14 | "axios": "^0.18.0", 15 | "element-ui": "^2.3.6", 16 | "qs": "^6.5.1", 17 | "vue": "^2.5.2", 18 | "vue-router": "^3.0.1", 19 | "vuex": "^3.0.1" 20 | }, 21 | "devDependencies": { 22 | "autoprefixer": "^7.1.2", 23 | "babel-core": "^6.22.1", 24 | "babel-eslint": "^8.2.1", 25 | "babel-helper-vue-jsx-merge-props": "^2.0.3", 26 | "babel-loader": "^7.1.1", 27 | "babel-plugin-syntax-jsx": "^6.18.0", 28 | "babel-plugin-transform-runtime": "^6.22.0", 29 | "babel-plugin-transform-vue-jsx": "^3.5.0", 30 | "babel-preset-env": "^1.3.2", 31 | "babel-preset-stage-2": "^6.22.0", 32 | "chalk": "^2.0.1", 33 | "copy-webpack-plugin": "^4.0.1", 34 | "css-loader": "^0.28.0", 35 | "eslint": "^4.15.0", 36 | "eslint-config-standard": "^10.2.1", 37 | "eslint-friendly-formatter": "^3.0.0", 38 | "eslint-loader": "^1.7.1", 39 | "eslint-plugin-import": "^2.7.0", 40 | "eslint-plugin-node": "^5.2.0", 41 | "eslint-plugin-promise": "^3.4.0", 42 | "eslint-plugin-standard": "^3.0.1", 43 | "eslint-plugin-vue": "^4.0.0", 44 | "extract-text-webpack-plugin": "^3.0.0", 45 | "file-loader": "^1.1.4", 46 | "friendly-errors-webpack-plugin": "^1.6.1", 47 | "html-webpack-plugin": "^2.30.1", 48 | "node-notifier": "^5.1.2", 49 | "optimize-css-assets-webpack-plugin": "^3.2.0", 50 | "ora": "^1.2.0", 51 | "portfinder": "^1.0.13", 52 | "postcss-import": "^11.0.0", 53 | "postcss-loader": "^2.0.8", 54 | "postcss-url": "^7.2.1", 55 | "rimraf": "^2.6.0", 56 | "semver": "^5.3.0", 57 | "shelljs": "^0.7.6", 58 | "uglifyjs-webpack-plugin": "^1.1.1", 59 | "url-loader": "^0.5.8", 60 | "vue-loader": "^13.3.0", 61 | "vue-style-loader": "^3.0.1", 62 | "vue-template-compiler": "^2.5.2", 63 | "webpack": "^3.6.0", 64 | "webpack-bundle-analyzer": "^2.9.0", 65 | "webpack-dev-server": "^2.9.1", 66 | "webpack-merge": "^4.1.0" 67 | }, 68 | "engines": { 69 | "node": ">= 6.0.0", 70 | "npm": ">= 3.0.0" 71 | }, 72 | "browserslist": [ 73 | "> 1%", 74 | "last 2 versions", 75 | "not ie <= 8" 76 | ] 77 | } 78 | -------------------------------------------------------------------------------- /web/vue/src/App.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 43 | 71 | -------------------------------------------------------------------------------- /web/vue/src/api/host.js: -------------------------------------------------------------------------------- 1 | import httpClient from '../utils/httpClient' 2 | 3 | export default { 4 | // 任务列表 5 | list (query, callback) { 6 | httpClient.get('/host', query, callback) 7 | }, 8 | 9 | all (query, callback) { 10 | httpClient.get('/host/all', {}, callback) 11 | }, 12 | 13 | detail (id, callback) { 14 | httpClient.get(`/host/${id}`, {}, callback) 15 | }, 16 | 17 | update (data, callback) { 18 | httpClient.post('/host/store', data, callback) 19 | }, 20 | 21 | remove (id, callback) { 22 | httpClient.post(`/host/remove/${id}`, {}, callback) 23 | }, 24 | 25 | ping (id, callback) { 26 | httpClient.get(`/host/ping/${id}`, {}, callback) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /web/vue/src/api/install.js: -------------------------------------------------------------------------------- 1 | import httpClient from '../utils/httpClient' 2 | 3 | export default { 4 | store (data, callback) { 5 | httpClient.post('/install/store', data, callback) 6 | }, 7 | status (callback) { 8 | httpClient.get('/install/status', {}, callback) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /web/vue/src/api/notification.js: -------------------------------------------------------------------------------- 1 | import httpClient from '../utils/httpClient' 2 | 3 | export default { 4 | slack (callback) { 5 | httpClient.get('/system/slack', {}, callback) 6 | }, 7 | updateSlack (data, callback) { 8 | httpClient.post('/system/slack/update', data, callback) 9 | }, 10 | createSlackChannel (channel, callback) { 11 | httpClient.post('/system/slack/channel', {channel}, callback) 12 | }, 13 | removeSlackChannel (channelId, callback) { 14 | httpClient.post(`/system/slack/channel/remove/${channelId}`, {}, callback) 15 | }, 16 | mail (callback) { 17 | httpClient.get('/system/mail', {}, callback) 18 | }, 19 | updateMail (data, callback) { 20 | httpClient.post('/system/mail/update', data, callback) 21 | }, 22 | createMailUser (data, callback) { 23 | httpClient.post('/system/mail/user', data, callback) 24 | }, 25 | removeMailUser (userId, callback) { 26 | httpClient.post(`/system/mail/user/remove/${userId}`, {}, callback) 27 | }, 28 | webhook (callback) { 29 | httpClient.get('/system/webhook', {}, callback) 30 | }, 31 | updateWebHook (data, callback) { 32 | httpClient.post('/system/webhook/update', data, callback) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /web/vue/src/api/system.js: -------------------------------------------------------------------------------- 1 | import httpClient from '../utils/httpClient' 2 | 3 | export default { 4 | loginLogList (query, callback) { 5 | httpClient.get('/system/login-log', query, callback) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /web/vue/src/api/task.js: -------------------------------------------------------------------------------- 1 | import httpClient from '../utils/httpClient' 2 | 3 | export default { 4 | // 任务列表 5 | list (query, callback) { 6 | httpClient.batchGet([ 7 | { 8 | uri: '/task', 9 | params: query 10 | }, 11 | { 12 | uri: '/host/all' 13 | } 14 | ], callback) 15 | }, 16 | 17 | detail (id, callback) { 18 | httpClient.batchGet([ 19 | { 20 | uri: `/task/${id}` 21 | }, 22 | { 23 | uri: '/host/all' 24 | } 25 | ], callback) 26 | }, 27 | 28 | update (data, callback) { 29 | httpClient.post('/task/store', data, callback) 30 | }, 31 | 32 | remove (id, callback) { 33 | httpClient.post(`/task/remove/${id}`, {}, callback) 34 | }, 35 | 36 | enable (id, callback) { 37 | httpClient.post(`/task/enable/${id}`, {}, callback) 38 | }, 39 | 40 | disable (id, callback) { 41 | httpClient.post(`/task/disable/${id}`, {}, callback) 42 | }, 43 | 44 | run (id, callback) { 45 | httpClient.get(`/task/run/${id}`, {}, callback) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /web/vue/src/api/taskLog.js: -------------------------------------------------------------------------------- 1 | import httpClient from '../utils/httpClient' 2 | 3 | export default { 4 | list (query, callback) { 5 | httpClient.get('/task/log', query, callback) 6 | }, 7 | 8 | clear (callback) { 9 | httpClient.post('/task/log/clear', {}, callback) 10 | }, 11 | 12 | stop (id, taskId, callback) { 13 | httpClient.post('/task/log/stop', {id, task_id: taskId}, callback) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /web/vue/src/api/user.js: -------------------------------------------------------------------------------- 1 | import httpClient from '../utils/httpClient' 2 | 3 | export default { 4 | list (query, callback) { 5 | httpClient.get('/user', {}, callback) 6 | }, 7 | 8 | detail (id, callback) { 9 | httpClient.get(`/user/${id}`, {}, callback) 10 | }, 11 | 12 | update (data, callback) { 13 | httpClient.post('/user/store', data, callback) 14 | }, 15 | 16 | login (username, password, callback) { 17 | httpClient.post('/user/login', {username, password}, callback) 18 | }, 19 | 20 | enable (id, callback) { 21 | httpClient.post(`/user/enable/${id}`, {}, callback) 22 | }, 23 | 24 | disable (id, callback) { 25 | httpClient.post(`/user/disable/${id}`, {}, callback) 26 | }, 27 | 28 | remove (id, callback) { 29 | httpClient.post(`/user/remove/${id}`, {}, callback) 30 | }, 31 | 32 | editPassword (data, callback) { 33 | httpClient.post(`/user/editPassword/${data.id}`, { 34 | 'new_password': data.new_password, 35 | 'confirm_new_password': data.confirm_new_password 36 | }, callback) 37 | }, 38 | 39 | editMyPassword (data, callback) { 40 | httpClient.post(`/user/editMyPassword`, data, callback) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /web/vue/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ouqiang/gocron/960fc988b11ab32a121e4448f600c5ea089962b4/web/vue/src/assets/logo.png -------------------------------------------------------------------------------- /web/vue/src/components/common/footer.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 13 | -------------------------------------------------------------------------------- /web/vue/src/components/common/header.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 14 | -------------------------------------------------------------------------------- /web/vue/src/components/common/navMenu.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 60 | -------------------------------------------------------------------------------- /web/vue/src/components/common/notFound.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 27 | -------------------------------------------------------------------------------- /web/vue/src/main.js: -------------------------------------------------------------------------------- 1 | // The Vue build version to load with the `import` command 2 | // (runtime-only or standalone) has been set in webpack.base.conf with an alias. 3 | import Vue from 'vue' 4 | import ElementUI from 'element-ui' 5 | import 'element-ui/lib/theme-chalk/index.css' 6 | import App from './App' 7 | import router from './router' 8 | import store from './store/index' 9 | 10 | Vue.config.productionTip = false 11 | Vue.use(ElementUI) 12 | 13 | Vue.directive('focus', { 14 | inserted: function (el) { 15 | // 聚焦元素 16 | el.focus() 17 | } 18 | }) 19 | 20 | Vue.prototype.$appConfirm = function (callback) { 21 | this.$confirm('确定执行此操作?', '提示', { 22 | confirmButtonText: '确定', 23 | cancelButtonText: '取消', 24 | type: 'warning' 25 | }).then(() => { 26 | callback() 27 | }) 28 | } 29 | 30 | Vue.filter('formatTime', function (time) { 31 | const fillZero = function (num) { 32 | return num >= 10 ? num : '0' + num 33 | } 34 | const date = new Date(time) 35 | 36 | const result = date.getFullYear() + '-' + 37 | (fillZero(date.getMonth() + 1)) + '-' + 38 | fillZero(date.getDate()) + ' ' + 39 | fillZero(date.getHours()) + ':' + 40 | fillZero(date.getMinutes()) + ':' + 41 | fillZero(date.getSeconds()) 42 | 43 | if (result.indexOf('20') !== 0) { 44 | return '' 45 | } 46 | 47 | return result 48 | }) 49 | 50 | /* eslint-disable no-new */ 51 | new Vue({ 52 | el: '#app', 53 | router, 54 | store, 55 | components: { App }, 56 | template: '' 57 | }) 58 | -------------------------------------------------------------------------------- /web/vue/src/pages/host/edit.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 100 | -------------------------------------------------------------------------------- /web/vue/src/pages/host/list.vue: -------------------------------------------------------------------------------- 1 | 79 | 80 | 155 | -------------------------------------------------------------------------------- /web/vue/src/pages/install/index.vue: -------------------------------------------------------------------------------- 1 | 84 | 85 | 177 | -------------------------------------------------------------------------------- /web/vue/src/pages/system/loginLog.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 81 | -------------------------------------------------------------------------------- /web/vue/src/pages/system/notification/email.vue: -------------------------------------------------------------------------------- 1 | 77 | 78 | 173 | 174 | 179 | -------------------------------------------------------------------------------- /web/vue/src/pages/system/notification/slack.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 123 | 124 | 129 | -------------------------------------------------------------------------------- /web/vue/src/pages/system/notification/tab.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 41 | -------------------------------------------------------------------------------- /web/vue/src/pages/system/notification/webhook.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 81 | -------------------------------------------------------------------------------- /web/vue/src/pages/system/sidebar.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 32 | -------------------------------------------------------------------------------- /web/vue/src/pages/task/sidebar.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 32 | -------------------------------------------------------------------------------- /web/vue/src/pages/user/edit.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 111 | -------------------------------------------------------------------------------- /web/vue/src/pages/user/editMyPassword.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 67 | -------------------------------------------------------------------------------- /web/vue/src/pages/user/editPassword.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 68 | -------------------------------------------------------------------------------- /web/vue/src/pages/user/list.vue: -------------------------------------------------------------------------------- 1 | 71 | 72 | 148 | -------------------------------------------------------------------------------- /web/vue/src/pages/user/login.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 77 | -------------------------------------------------------------------------------- /web/vue/src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | import store from '../store/index' 4 | import NotFound from '../components/common/notFound' 5 | 6 | import TaskList from '../pages/task/list' 7 | import TaskEdit from '../pages/task/edit' 8 | import TaskLog from '../pages/taskLog/list' 9 | 10 | import HostList from '../pages/host/list' 11 | import HostEdit from '../pages/host/edit' 12 | 13 | import UserList from '../pages/user/list' 14 | import UserEdit from '../pages/user/edit' 15 | import UserLogin from '../pages/user/login' 16 | import UserEditPassword from '../pages/user/editPassword' 17 | import UserEditMyPassword from '../pages/user/editMyPassword' 18 | 19 | import NotificationEmail from '../pages/system/notification/email' 20 | import NotificationSlack from '../pages/system/notification/slack' 21 | import NotificationWebhook from '../pages/system/notification/webhook' 22 | 23 | import Install from '../pages/install/index' 24 | import LoginLog from '../pages/system/loginLog' 25 | 26 | Vue.use(Router) 27 | 28 | const router = new Router({ 29 | routes: [ 30 | { 31 | path: '*', 32 | component: NotFound, 33 | meta: { 34 | noLogin: true, 35 | noNeedAdmin: true 36 | } 37 | }, 38 | { 39 | path: '/', 40 | redirect: '/task' 41 | }, 42 | { 43 | path: '/install', 44 | name: 'install', 45 | component: Install, 46 | meta: { 47 | noLogin: true, 48 | noNeedAdmin: true 49 | } 50 | }, 51 | { 52 | path: '/task', 53 | name: 'task-list', 54 | component: TaskList, 55 | meta: { 56 | noNeedAdmin: true 57 | } 58 | }, 59 | { 60 | path: '/task/create', 61 | name: 'task-create', 62 | component: TaskEdit 63 | }, 64 | { 65 | path: '/task/edit/:id', 66 | name: 'task-edit', 67 | component: TaskEdit 68 | }, 69 | { 70 | path: '/task/log', 71 | name: 'task-log', 72 | component: TaskLog, 73 | meta: { 74 | noNeedAdmin: true 75 | } 76 | }, 77 | { 78 | path: '/host', 79 | name: 'host-list', 80 | component: HostList, 81 | meta: { 82 | noNeedAdmin: true 83 | } 84 | }, 85 | { 86 | path: '/host/create', 87 | name: 'host-create', 88 | component: HostEdit 89 | }, 90 | { 91 | path: '/host/edit/:id', 92 | name: 'host-edit', 93 | component: HostEdit 94 | }, 95 | { 96 | path: '/user', 97 | name: 'user-list', 98 | component: UserList 99 | }, 100 | { 101 | path: '/user/create', 102 | name: 'user-create', 103 | component: UserEdit 104 | }, 105 | { 106 | path: '/user/edit/:id', 107 | name: 'user-edit', 108 | component: UserEdit 109 | }, 110 | { 111 | path: '/user/login', 112 | name: 'user-login', 113 | component: UserLogin, 114 | meta: { 115 | noLogin: true 116 | } 117 | }, 118 | { 119 | path: '/user/edit-password/:id', 120 | name: 'user-edit-password', 121 | component: UserEditPassword 122 | }, 123 | { 124 | path: '/user/edit-my-password', 125 | name: 'user-edit-my-password', 126 | component: UserEditMyPassword, 127 | meta: { 128 | noNeedAdmin: true 129 | } 130 | }, 131 | { 132 | path: '/system', 133 | redirect: '/system/notification/email' 134 | }, 135 | { 136 | path: '/system/notification/email', 137 | name: 'system-notification-email', 138 | component: NotificationEmail 139 | }, 140 | { 141 | path: '/system/notification/slack', 142 | name: 'system-notification-slack', 143 | component: NotificationSlack 144 | }, 145 | { 146 | path: '/system/notification/webhook', 147 | name: 'system-notification-webhook', 148 | component: NotificationWebhook 149 | }, 150 | { 151 | path: '/system/login-log', 152 | name: 'login-log', 153 | component: LoginLog 154 | } 155 | ] 156 | }) 157 | 158 | router.beforeEach((to, from, next) => { 159 | if (to.meta.noLogin) { 160 | next() 161 | return 162 | } 163 | if (store.getters.user.token) { 164 | if ((store.getters.user.isAdmin || to.meta.noNeedAdmin)) { 165 | next() 166 | return 167 | } 168 | if (!store.getters.user.isAdmin) { 169 | next( 170 | { 171 | path: '/404.html' 172 | } 173 | ) 174 | return 175 | } 176 | } 177 | 178 | next({ 179 | path: '/user/login', 180 | query: {redirect: to.fullPath} 181 | }) 182 | }) 183 | 184 | export default router 185 | -------------------------------------------------------------------------------- /web/vue/src/storage/user.js: -------------------------------------------------------------------------------- 1 | class User { 2 | get () { 3 | return { 4 | 'token': this.getToken(), 5 | 'uid': this.getUid(), 6 | 'username': this.getUsername(), 7 | 'isAdmin': this.getIsAdmin() 8 | } 9 | } 10 | 11 | getToken () { 12 | return localStorage.getItem('token') || '' 13 | } 14 | 15 | setToken (token) { 16 | localStorage.setItem('token', token) 17 | return this 18 | } 19 | 20 | clear () { 21 | localStorage.clear() 22 | } 23 | 24 | getUid () { 25 | return localStorage.getItem('uid') || '' 26 | } 27 | 28 | setUid (uid) { 29 | localStorage.setItem('uid', uid) 30 | return this 31 | } 32 | 33 | getUsername () { 34 | return localStorage.getItem('username') || '' 35 | } 36 | 37 | setUsername (username) { 38 | localStorage.setItem('username', username) 39 | return this 40 | } 41 | 42 | getIsAdmin () { 43 | let isAdmin = localStorage.getItem('is_admin') 44 | return isAdmin === '1' 45 | } 46 | 47 | setIsAdmin (isAdmin) { 48 | localStorage.setItem('is_admin', isAdmin) 49 | return this 50 | } 51 | } 52 | 53 | export default new User() 54 | -------------------------------------------------------------------------------- /web/vue/src/store/index.js: -------------------------------------------------------------------------------- 1 | import vue from 'vue' 2 | import vuex from 'vuex' 3 | import userStorage from '../storage/user' 4 | 5 | vue.use(vuex) 6 | export default new vuex.Store({ 7 | state: { 8 | hiddenNavMenu: false, 9 | user: userStorage.get() 10 | }, 11 | getters: { 12 | user (state) { 13 | return state.user 14 | }, 15 | login (state) { 16 | return state.user.token !== '' 17 | } 18 | }, 19 | mutations: { 20 | hiddenNavMenu (state) { 21 | state.hiddenNavMenu = true 22 | }, 23 | showNavMenu (state) { 24 | state.hiddenNavMenu = false 25 | }, 26 | setUser (state, user) { 27 | userStorage.setToken(user.token) 28 | userStorage.setUid(user.uid) 29 | userStorage.setUsername(user.username) 30 | userStorage.setIsAdmin(user.isAdmin) 31 | state.user = user 32 | }, 33 | logout (state) { 34 | userStorage.clear() 35 | state.user = userStorage.get() 36 | } 37 | } 38 | }) 39 | -------------------------------------------------------------------------------- /web/vue/src/utils/httpClient.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import {Message} from 'element-ui' 3 | import router from '../router/index' 4 | import store from '../store/index' 5 | import Qs from 'qs' 6 | 7 | const errorMessage = '加载失败, 请稍后再试' 8 | // 成功状态码 9 | const SUCCESS_CODE = 0 10 | // 认证失败 11 | const AUTH_ERROR_CODE = 401 12 | // 应用未安装 13 | const APP_NOT_INSTALL_CODE = 801 14 | 15 | axios.defaults.baseURL = 'api' 16 | axios.defaults.timeout = 10000 17 | axios.defaults.responseType = 'json' 18 | axios.interceptors.request.use(config => { 19 | config.headers['Auth-Token'] = store.getters.user.token 20 | return config 21 | }, error => { 22 | Message.error({ 23 | message: errorMessage 24 | }) 25 | 26 | return Promise.reject(error) 27 | }) 28 | 29 | axios.interceptors.response.use(data => { 30 | return data 31 | }, error => { 32 | Message.error({ 33 | message: errorMessage 34 | }) 35 | 36 | return Promise.reject(error) 37 | }) 38 | 39 | function handle (promise, next) { 40 | promise.then((res) => successCallback(res, next)) 41 | .catch((error) => failureCallback(error)) 42 | } 43 | 44 | function checkResponseCode (code, msg) { 45 | switch (code) { 46 | // 应用未安装 47 | case APP_NOT_INSTALL_CODE: 48 | router.push('/install') 49 | return false 50 | // 认证失败 51 | case AUTH_ERROR_CODE: 52 | router.push('/user/login') 53 | return false 54 | } 55 | if (code !== SUCCESS_CODE) { 56 | Message.error({ 57 | message: msg 58 | }) 59 | return false 60 | } 61 | 62 | return true 63 | } 64 | 65 | function successCallback (res, next) { 66 | if (!checkResponseCode(res.data.code, res.data.message)) { 67 | return 68 | } 69 | if (!next) { 70 | return 71 | } 72 | next(res.data.data, res.data.code, res.data.message) 73 | } 74 | 75 | function failureCallback (error) { 76 | Message.error({ 77 | message: '请求失败 - ' + error 78 | }) 79 | } 80 | 81 | export default { 82 | get (uri, params, next) { 83 | const promise = axios.get(uri, {params}) 84 | handle(promise, next) 85 | }, 86 | 87 | batchGet (uriGroup, next) { 88 | const requests = [] 89 | for (let item of uriGroup) { 90 | let params = {} 91 | if (item.params !== undefined) { 92 | params = item.params 93 | } 94 | requests.push(axios.get(item.uri, {params})) 95 | } 96 | 97 | axios.all(requests).then(axios.spread(function (...res) { 98 | const result = [] 99 | for (let item of res) { 100 | if (!checkResponseCode(item.data.code, item.data.message)) { 101 | return 102 | } 103 | result.push(item.data.data) 104 | } 105 | next(...result) 106 | })).catch((error) => failureCallback(error)) 107 | }, 108 | 109 | post (uri, data, next) { 110 | const promise = axios.post(uri, Qs.stringify(data), { 111 | headers: { 112 | post: { 113 | 'Content-Type': 'application/x-www-form-urlencoded' 114 | } 115 | } 116 | }) 117 | handle(promise, next) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /web/vue/static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ouqiang/gocron/960fc988b11ab32a121e4448f600c5ea089962b4/web/vue/static/.gitkeep --------------------------------------------------------------------------------