增加slack消息通知

pull/21/merge
ouqiang 2017-04-28 11:54:46 +08:00
parent bafafadae1
commit a780076a57
23 changed files with 401 additions and 45 deletions

View File

View File

@ -5,8 +5,6 @@ import (
"github.com/go-xorm/xorm"
)
// 主机
type Host struct {
Id int16 `xorm:"smallint pk autoincr"`

View File

@ -12,8 +12,9 @@ func (migration *Migration) Exec(dbName string) error {
if !isDatabaseExist(dbName) {
return errors.New("数据库不存在")
}
setting := new(Setting)
tables := []interface{}{
&User{}, &Task{}, &TaskLog{}, &Host{},
&User{}, &Task{}, &TaskLog{}, &Host{}, setting,
}
for _, table := range tables {
exist, err:= Db.IsTableExist(table)
@ -28,6 +29,10 @@ func (migration *Migration) Exec(dbName string) error {
return err
}
}
err := setting.InitBasicField()
if err != nil {
return err
}
return nil
}

44
models/setting.go Normal file
View File

@ -0,0 +1,44 @@
package models
type Setting struct {
Id int16 `xorm:"smallint pk autoincr"`
Code string `xorm:"varchar(32) notnull"`
Key string `xorm:"varchar(64) notnull"`
Value string `xorm:"varchar(4096) notnull default '' "`
}
const SlackCode = "slack"
const SlackKey = "url"
// 初始化基本字段 邮件、slack等
func (setting *Setting) InitBasicField() (error) {
setting.Code = "slack";
setting.Key = "url"
setting.Value = ""
_, err := Db.Insert(setting)
return err
}
func (setting *Setting) SlackUrl() (string, error) {
setting.slackCondition()
_, err := Db.Get(setting)
return setting.Value, err
}
func (setting *Setting) UpdateSlackUrl(url string) (int64, error) {
setting.slackCondition()
setting.Value = url
return setting.UpdateBean()
}
func (setting *Setting) slackCondition() {
setting.Code = SlackCode
setting.Key = SlackKey
}
func (setting *Setting) UpdateBean() (int64, error) {
return Db.Cols("code,key,value").Update(setting)
}

View File

@ -58,7 +58,7 @@ func (task *Task) Create() (insertId int, err error) {
}
func (task *Task) UpdateBean(id int) (int64, error) {
return Db.ID(id).UseBool("status,multi").Update(task)
return Db.ID(id).Cols("name,spec,protocol,command,timeout,multi,retry_times,host_id,remark,status").Update(task)
}
// 更新
@ -91,7 +91,7 @@ func (task *Task) ActiveList() ([]TaskHost, error) {
}
// 获取某个主机下的所有激活任务
func (task *Task) ActiveListByHostId(hostId int16) ([]TaskHost, error) {
func (task *Task) ActiveListByHostId(hostId int16) ([]TaskHost, error) {
list := make([]TaskHost, 0)
fields := "t.*, host.alias,host.name,host.username,host.password,host.port,host.auth_type,host.private_key"
err := Db.Alias("t").Join("LEFT", hostTableName(), "t.host_id=host.id").Where("t.status = ? AND t.host_id = ?", Enabled, hostId).Cols(fields).Find(&list)

View File

@ -22,7 +22,7 @@ type TaskLog struct {
StartTime time.Time `xorm:"datetime created"` // 开始执行时间
EndTime time.Time `xorm:"datetime updated"` // 执行完成(失败)时间
Status Status `xorm:"tinyint notnull default 1"` // 状态 0:执行失败 1:执行中 2:执行完毕 3:任务取消(上次任务未执行完成)
Result string `xorm:"text notnull defalut '' "` // 执行结果
Result string `xorm:"mediumtext notnull defalut '' "` // 执行结果
TotalTime int `xorm:"-"` // 执行总时长
BaseModel `xorm:"-"`
}

View File

@ -8,6 +8,7 @@ import (
"github.com/ouqiang/gocron/modules/setting"
"github.com/ouqiang/gocron/modules/logger"
"runtime"
"github.com/ouqiang/gocron/modules/notify"
)
var (
@ -36,6 +37,8 @@ func InitEnv() {
if Installed {
InitDb()
InitResource()
settingModel := new(models.Setting)
notify.SlackUrl, _ = settingModel.SlackUrl()
}
}

View File

@ -0,0 +1,72 @@
package httpclient
// http-client
import (
"io/ioutil"
"net/http"
"time"
"fmt"
"bytes"
)
type ResponseWrapper struct {
StatusCode int
Body string
Header http.Header
}
func Get(url string, timeout int) ResponseWrapper {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return createRequestError(err)
}
return request(req, timeout)
}
func PostBody(url string, body string, timeout int) ResponseWrapper {
buf := bytes.NewBufferString(body)
req, err := http.NewRequest("POST", url, buf)
if err != nil {
return createRequestError(err)
}
req.Header.Set("Content-type", "application/json")
return request(req, timeout)
}
func request(req *http.Request, timeout int) ResponseWrapper {
wrapper := ResponseWrapper{StatusCode: 0, Body: "", Header: make(http.Header)}
client := &http.Client{}
if timeout > 0 {
client.Timeout = time.Duration(timeout) * time.Second
}
setRequestHeader(req)
resp, err := client.Do(req)
if err != nil {
wrapper.Body = fmt.Sprintf("执行HTTP请求错误-%s", err.Error())
return wrapper
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
wrapper.Body = fmt.Sprintf("读取HTTP请求返回值失败-%s", err.Error())
return wrapper
}
wrapper.StatusCode = resp.StatusCode
wrapper.Body = string(body)
wrapper.Header = resp.Header
return wrapper
}
func setRequestHeader(req *http.Request) {
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.8,en;q=0.6")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.133 Safari/537.36 golang/gocron")
}
func createRequestError(err error) ResponseWrapper {
errorMessage := fmt.Sprintf("创建HTTP请求错误-%s", err.Error())
return ResponseWrapper{0, errorMessage, make(http.Header)}
}

32
modules/notify/notify.go Normal file
View File

@ -0,0 +1,32 @@
package notify
import (
"time"
)
var SlackUrl string
type Message map[string]interface{}
type Notifiable interface {
send(msg Message)
}
var queue chan Message = make(chan Message, 100)
func init() {
go run()
}
// 把消息推入队列
func Push(msg Message) {
queue <- msg
}
func run() {
slack := new(Slack)
for msg := range queue {
// 根据任务配置执行相应通知
go slack.Send(msg)
time.Sleep(1 * time.Second)
}
}

53
modules/notify/slack.go Normal file
View File

@ -0,0 +1,53 @@
package notify
// 发送消息到slack
import (
"fmt"
"strings"
"github.com/ouqiang/gocron/modules/httpclient"
"github.com/ouqiang/gocron/modules/logger"
"github.com/ouqiang/gocron/modules/utils"
)
type Slack struct {}
func (slack *Slack) Send(msg Message) {
name, nameOk := msg["name"]
statusName, statusOk := msg["status"]
content, contentOk := msg["output"]
if SlackUrl == "" {
logger.Error("slack#webhooks-url为空")
return;
}
if !nameOk || !statusOk || !contentOk {
logger.Error("slack#消息字段不存在")
return
}
body := fmt.Sprintf("============\n============\n============\n任务名称: %s\n状态: %s\n输出:\n %s\n", name, statusName, content)
formatBody := slack.format(body)
timeout := 30
maxTimes := 3
i := 0
for i < maxTimes {
resp := httpclient.PostBody(SlackUrl, formatBody, timeout)
if resp.StatusCode == 200 {
break;
}
i += 1
if i < maxTimes {
logger.Error("slack#发送消息失败#%s#消息内容-%s", resp.Body, body)
}
}
}
// 格式化消息内容
func (slack *Slack) format(content string) string {
content = utils.EscapeJson(content)
specialChars := []string{"&", "<", ">"}
replaceChars := []string{"&amp;", "&lt;", "&gt;"}
for i, v := range specialChars {
content = strings.Replace(content, v, replaceChars[i], 1000)
}
return fmt.Sprintf(`{"text":"%s","username":"监控"}`, content)
}

View File

@ -8,6 +8,7 @@ import (
"time"
"runtime"
"github.com/Tang-RoseChild/mahonia"
"strings"
)
@ -72,4 +73,15 @@ func GBK2UTF8(s string) (string, bool) {
dec := mahonia.NewDecoder("gbk")
return dec.ConvertStringOK(s)
}
// 转义json特殊字符
func EscapeJson(s string) string {
specialChars := []string{"\\", "\b","\f", "\n", "\r", "\t", "\"",}
replaceChars := []string{ "\\\\", "\\b", "\\f", "\\n", "\\r", "\\t", "\\\"",}
for i, v := range specialChars {
s = strings.Replace(s, v, replaceChars[i], 1000)
}
return s
}

View File

@ -21,9 +21,10 @@ type InstallForm struct {
DbUsername string `binding:"Required;MaxSize(50)"`
DbPassword string `binding:"Required;MaxSize(30)"`
DbName string `binding:"Required;MaxSize(50)"`
DbTablePrefix string `binding:"MinSize(20)"`
AdminUsername string `binding:"Required;MaxSize(3)"`
AdminPassword string `binding:"Required;MaxSize(6)"`
DbTablePrefix string `binding:"MaxSize(20)"`
AdminUsername string `binding:"Required;MinSize(3)"`
AdminPassword string `binding:"Required;MinSize(6)"`
ConfirmAdminPassword string `binding:"Required;MinSize(6)"`
AdminEmail string `binding:"Required;Email;MaxSize(50)"`
}
@ -46,6 +47,9 @@ func Store(ctx *macaron.Context, form InstallForm) string {
if app.Installed {
return json.CommonFailure("系统已安装!")
}
if form.AdminPassword != form.ConfirmAdminPassword {
return json.CommonFailure("两次输入密码不匹配")
}
err := testDbConnection(form)
if err != nil {
return json.CommonFailure("数据库连接失败", err)

View File

@ -16,6 +16,7 @@ import (
"github.com/ouqiang/gocron/modules/logger"
"github.com/ouqiang/gocron/routers/user"
"github.com/go-macaron/gzip"
"github.com/ouqiang/gocron/routers/setting"
)
// 静态文件目录
@ -64,6 +65,14 @@ func Register(m *macaron.Macaron) {
m.Post("/remove/:id", host.Remove)
})
// 管理
m.Group("/admin", func() {
m.Group("/setting/", func() {
m.Get("/slack", setting.EditSlack)
})
}, adminAuth)
// 404错误
m.NotFound(func(ctx *macaron.Context) {
if isGetRequest(ctx) && !isAjaxRequest(ctx) {
@ -116,7 +125,6 @@ func RegisterMiddleware(m *macaron.Macaron) {
setShareData(m)
}
// 系统未安装,重定向到安装页面
func checkAppInstall(m *macaron.Macaron) {
m.Use(func(ctx *macaron.Context) {
@ -170,6 +178,14 @@ func setShareData(m *macaron.Macaron) {
})
}
// 管理员认证
func adminAuth(ctx *macaron.Context, sess session.Store) {
if !user.IsAdmin(sess) {
ctx.Data["Title"] = "无权限访问此页面"
ctx.HTML(403, "error/no_permission")
}
}
func isAjaxRequest(ctx *macaron.Context) bool {
req := ctx.Req.Header.Get("X-Requested-With")
if req == "XMLHttpRequest" {

View File

@ -0,0 +1,31 @@
package setting
import (
"gopkg.in/macaron.v1"
"github.com/ouqiang/gocron/modules/utils"
"github.com/ouqiang/gocron/models"
"github.com/ouqiang/gocron/modules/logger"
)
func EditSlack(ctx *macaron.Context) {
ctx.Data["Title"] = "slack配置"
settingModel := new(models.Setting)
url, err := settingModel.SlackUrl()
if err != nil {
logger.Error(err)
}
ctx.Data["SlackUrl"] = url
ctx.HTML(200, "setting/slack")
}
func StoreSlack(ctx *macaron.Context) string {
url := ctx.QueryTrim("url")
settingModel := new(models.Setting)
_, err := settingModel.UpdateSlackUrl(url)
json := utils.JsonResponse{}
if err != nil {
return json.CommonFailure(utils.FailureContent, err)
}
return json.Success(utils.SuccessContent, nil)
}

View File

@ -31,6 +31,7 @@ func ValidateLogin(ctx *macaron.Context, sess session.Store) string {
sess.Set("username", userModel.Name)
sess.Set("uid", userModel.Id)
sess.Set("isAdmin", userModel.IsAdmin)
return json.Success("登录成功", nil)
}
@ -70,5 +71,14 @@ func IsLogin(sess session.Store) bool {
return true
}
return false
}
func IsAdmin(sess session.Store) bool {
isAdmin, ok := sess.Get("isAdmin").(int8)
if ok && isAdmin > 0 {
return true
}
return false
}

View File

@ -2,8 +2,6 @@ package service
import (
"github.com/ouqiang/gocron/models"
"io/ioutil"
"net/http"
"strconv"
"time"
"github.com/ouqiang/gocron/modules/logger"
@ -12,12 +10,14 @@ import (
"github.com/ouqiang/gocron/modules/utils"
"errors"
"fmt"
"github.com/ouqiang/gocron/modules/httpclient"
"github.com/ouqiang/gocron/modules/notify"
)
var Cron *cron.Cron
var runInstance Instance
// 任务ID作为Key, 不会出现并发写, 不加锁
// 任务ID作为Key, 不会出现并发写, 不加锁
type Instance struct {
Status map[int]bool
}
@ -136,35 +136,13 @@ func (h *LocalCommandHandler) runOnUnix(taskModel models.TaskHost) (string, erro
type HTTPHandler struct{}
func (h *HTTPHandler) Run(taskModel models.TaskHost) (result string, err error) {
client := &http.Client{}
if taskModel.Timeout > 0 {
client.Timeout = time.Duration(taskModel.Timeout) * time.Second
}
req, err := http.NewRequest("GET", taskModel.Command, nil)
if err != nil {
logger.Error("任务处理#创建HTTP请求错误-", err.Error())
return
}
req.Header.Set("Content-type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "golang/gocron")
resp, err := client.Do(req)
if err != nil {
logger.Error("任务处理HTTP请求错误-", err.Error())
return
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
logger.Error("任务处理#读取HTTP请求返回值失败-", err.Error())
return
}
resp := httpclient.Get(taskModel.Command, taskModel.Timeout)
// 返回状态码非200均为失败
if resp.StatusCode != 200 {
return string(body), errors.New(fmt.Sprintf("HTTP状态码非200-->%d", resp.StatusCode))
return resp.Body, errors.New(fmt.Sprintf("HTTP状态码非200-->%d", resp.StatusCode))
}
return string(body), err
return resp.Body, err
}
// SSH-command任务
@ -208,7 +186,7 @@ func updateTaskLog(taskLogId int64, taskResult TaskResult) (int64, error) {
var status models.Status
var result string = taskResult.Result
if taskResult.Err != nil {
result = taskResult.Err.Error() + " " + result
result = taskResult.Err.Error() + "\n" + result
status = models.Failure
} else {
status = models.Finish
@ -241,10 +219,27 @@ func createJob(taskModel models.TaskHost) cron.FuncJob {
return
}
taskResult := execJob(handler, taskModel)
if taskResult.Err != nil {
taskResult.Result = taskResult.Err.Error() + "\n" + taskResult.Result
}
_, err = updateTaskLog(taskLogId, taskResult)
if err != nil {
logger.Error("任务结束#更新任务日志失败-", err)
}
var statusName string
if taskResult.Err != nil {
statusName = "失败"
} else {
statusName = "成功"
}
msg := notify.Message{
"name": taskModel.Task.Name,
"output": taskResult.Result,
"status": statusName,
"taskId": taskModel.Id,
};
notify.Push(msg)
}
return taskFunc

View File

@ -61,7 +61,7 @@
<a class="item {{{if eq .Controller "host"}}}active{{{end}}}" href="/host"><i class="linux icon"></i>主机</a>
<!-- <a class="item {{{if eq .Controller "user"}}}active{{{end}}}" href="/user"><i class="user icon"></i>账户</a> -->
{{{if gt .LoginUid 0}}}
<a class="item {{{if eq .Controller "admin"}}}active{{{end}}}" href="/admin"><i class="settings icon"></i>管理</a>
<a class="item {{{if eq .Controller "admin"}}}active{{{end}}}" href="/admin/setting/slack"><i class="settings icon"></i>管理</a>
{{{end}}}
</div>
</div>

View File

@ -0,0 +1,12 @@
{{{ template "common/header" . }}}
<script>
swal({
title: "403 - FORBIDDEN",
text: "您无权限访问此页面",
type: "warning"
},
function(){
location.href = "/"
});
</script>
{{{ template "common/footer" . }}}

View File

@ -48,7 +48,7 @@
</div>
<div class="field">
<label>密码</label>
<input type="text" name="db_password">
<input type="password" name="db_password">
</div>
</div>
<div class="three fields">
@ -75,7 +75,13 @@
<div class="three fields">
<div class="field">
<label>管理员密码</label>
<input type="text" name="admin_password">
<input type="password" name="admin_password">
</div>
</div>
<div class="three fields">
<div class="field">
<label>确认管理员密码</label>
<input type="password" name="confirm_admin_password">
</div>
</div>
<div class="three fields">
@ -164,6 +170,15 @@
}
]
},
confirmAdminPassword: {
identifier : 'confirm_admin_password',
rules: [
{
type : 'match[admin_password]',
prompt : '两次输入密码不匹配'
}
]
},
adminEmail: {
identifier : 'admin_email',
rules: [

View File

@ -0,0 +1,9 @@
<div class="three wide column">
<div class="verticalMenu">
<div class="ui vertical pointing menu fluid">
<a class="{{{if eq .URI "/admin/setting/slack"}}}active teal{{{end}}} item" href="/admin/setting/slack">
<i class="slack icon"></i> Slack配置
</a>
</div>
</div>
</div>

View File

@ -0,0 +1,43 @@
{{{ template "common/header" . }}}
<div class="ui grid">
{{{template "setting/menu" .}}}
<div class="twelve wide column">
<div class="pageHeader">
<div class="segment">
<h3 class="ui dividing header">
<div class="content">
{{{.Title}}}
</div>
</h3>
</div>
</div>
<form class="ui form fluid vertical segment">
<div class="two fields">
<div class="field">
<label>
<div class="content">Slack WebHook URL</div>
</label>
<div class="ui small input">
<input type="text" name="url" value="{{{.SlackUrl}}}">
</div>
</div>
</div>
<div class="ui primary submit button">保存</div>
</form>
</div>
</div>
<script type="text/javascript">
var $uiForm = $('.ui.form');
$($uiForm).form(
{
onSuccess: function(event, fields) {
util.post('/admin/setting/slack', fields, function(code, message) {
location.reload();
});
return false;
}
}
)
</script>
{{{ template "common/footer" . }}}

View File

@ -41,6 +41,7 @@
<option value="1" {{{if eq .Params.Status 0}}}selected{{{end}}} >失败</option>
<option value="2" {{{if eq .Params.Status 1}}}selected{{{end}}}>执行中</option>
<option value="3" {{{if eq .Params.Status 2}}}selected{{{end}}}>成功</option>
<option value="4" {{{if eq .Params.Status 3}}}selected{{{end}}}>取消</option>
</select>
</div>
<div class="field">

View File

@ -69,7 +69,7 @@
</select>
</div>
</div>
<div class="four fields" id="hostField">
<div class="three fields" id="hostField">
<div class="field">
<label>主机</label>
<div class="ui blue message">
@ -81,7 +81,8 @@
<option value="{{{.Id}}}" {{{if $.Task}}}{{{if eq $.Task.HostId .Id }}} selected {{{end}}} {{{end}}}>{{{.Alias}}}-{{{.Name}}}</option>
{{{end}}}
</select> &nbsp; <a class="ui blue button" href="/host/create">添加主机</a>
</div>
</div>
</div>
<div class="two fields">
<div class="field">
@ -112,7 +113,7 @@
重试时间间隔 重试次数 * 分钟, 按1分钟、2分钟、3分钟.....的间隔进行重试<br>
取值范围1-10, 默认0不重试
</div>
<input type="text" name="timeout" value="{{{.Task.Timeout}}}">
<input type="text" name="retry_times" value="{{{.Task.RetryTimes}}}">
</div>
</div>
<div class="three fields">