🔊 v0.3.0 计划任务(定期备份等场景)

This commit is contained in:
naiba 2021-01-19 09:59:04 +08:00
parent f20a501ab4
commit d7a3ba607b
24 changed files with 417 additions and 38 deletions

View File

@ -11,7 +11,7 @@ on:
- "cmd/agent/**" - "cmd/agent/**"
- ".github/workflows/agent.yml" - ".github/workflows/agent.yml"
- ".goreleaser.yml" - ".goreleaser.yml"
- ".github/ISSUE_TEMPLATE/*" - ".github/ISSUE_TEMPLATE/**"
jobs: jobs:
deploy: deploy:
@ -25,5 +25,5 @@ jobs:
- name: Build and push dasbboard image - name: Build and push dasbboard image
run: | run: |
go test -v ./... go test -v ./...
docker build -t ghcr.io/${{ github.repository_owner }}/nezha-dashboard -f Dockerfile.dashboard . docker build -t ghcr.io/${{ github.repository_owner }}/nezha-dashboard -f Dockerfile .
docker push ghcr.io/${{ github.repository_owner }}/nezha-dashboard docker push ghcr.io/${{ github.repository_owner }}/nezha-dashboard

View File

@ -10,12 +10,14 @@ builds:
- windows - windows
- darwin - darwin
goarch: goarch:
- amd64 - arm
- arm64 - arm64
- 386 - 386
- arm - amd64
- mips - mips
- mips64 - mips64
gomips:
- softfloat
main: ./cmd/agent main: ./cmd/agent
binary: nezha-agent binary: nezha-agent
checksum: checksum:

View File

@ -6,9 +6,12 @@ COPY . .
RUN cd cmd/dashboard && go build -o app -ldflags="-s -w" RUN cd cmd/dashboard && go build -o app -ldflags="-s -w"
FROM alpine:latest FROM alpine:latest
ENV TZ="Asia/Shanghai"
RUN apk --no-cache --no-progress add \ RUN apk --no-cache --no-progress add \
ca-certificates \ ca-certificates \
tzdata tzdata && \
cp "/usr/share/zoneinfo/$TZ" /etc/localtime && \
echo "$TZ" > /etc/timezone
WORKDIR /dashboard WORKDIR /dashboard
COPY ./resource ./resource COPY ./resource ./resource
COPY --from=binarybuilder /dashboard/cmd/dashboard/app ./app COPY --from=binarybuilder /dashboard/cmd/dashboard/app ./app

View File

@ -205,7 +205,11 @@ URL 里面也可放置占位符,请求时会进行简单的字符串替换。
## 变更日志 ## 变更日志
最新:`dashboard 0.2.6` `agent 0.2.5`,只记录最后一次更新导致必须更新面板的说明。 最新:`dashboard 0.3.0` `agent 0.3.0`,只记录最后一次更新导致必须更新面板的说明。
- `dashboard 0.3.0` `agent 0.3.0` **重大更新**
增加了定时任务功能,可以定时在 Agent 上执行脚本(应用于定期备份、重启服务等计划运维场景)
- `dashboard 0.2.0` `agent 0.2.0` **重大更新** - `dashboard 0.2.0` `agent 0.2.0` **重大更新**

View File

@ -9,6 +9,7 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"os/exec"
"strings" "strings"
"time" "time"
@ -62,7 +63,7 @@ func doSelfUpdate() {
updateCh <- struct{}{} updateCh <- struct{}{}
}() }()
v := semver.MustParse(version) v := semver.MustParse(version)
log.Println("check update", v) log.Println("Check update", v)
latest, err := selfupdate.UpdateSelf(v, "naiba/nezha") latest, err := selfupdate.UpdateSelf(v, "naiba/nezha")
if err != nil { if err != nil {
log.Println("Binary update failed:", err) log.Println("Binary update failed:", err)
@ -84,7 +85,6 @@ func init() {
func main() { func main() {
// 来自于 GoReleaser 的版本号 // 来自于 GoReleaser 的版本号
dao.Version = version dao.Version = version
rootCmd.PersistentFlags().StringVarP(&server, "server", "s", "localhost:5555", "客户端ID") rootCmd.PersistentFlags().StringVarP(&server, "server", "s", "localhost:5555", "客户端ID")
rootCmd.PersistentFlags().StringVarP(&clientID, "id", "i", "", "客户端ID") rootCmd.PersistentFlags().StringVarP(&clientID, "id", "i", "", "客户端ID")
rootCmd.PersistentFlags().StringVarP(&clientSecret, "secret", "p", "", "客户端Secret") rootCmd.PersistentFlags().StringVarP(&clientSecret, "secret", "p", "", "客户端Secret")
@ -174,7 +174,7 @@ func doTask(task *pb.Task) {
result.Id = task.GetId() result.Id = task.GetId()
result.Type = task.GetType() result.Type = task.GetType()
switch task.GetType() { switch task.GetType() {
case model.MonitorTypeHTTPGET: case model.TaskTypeHTTPGET:
start := time.Now() start := time.Now()
resp, err := httpClient.Get(task.GetData()) resp, err := httpClient.Get(task.GetData())
if err == nil { if err == nil {
@ -196,7 +196,7 @@ func doTask(task *pb.Task) {
} else { } else {
result.Data = err.Error() result.Data = err.Error()
} }
case model.MonitorTypeICMPPing: case model.TaskTypeICMPPing:
pinger, err := ping.NewPinger(task.GetData()) pinger, err := ping.NewPinger(task.GetData())
if err == nil { if err == nil {
pinger.SetPrivileged(true) pinger.SetPrivileged(true)
@ -210,7 +210,7 @@ func doTask(task *pb.Task) {
} else { } else {
result.Data = err.Error() result.Data = err.Error()
} }
case model.MonitorTypeTCPPing: case model.TaskTypeTCPPing:
start := time.Now() start := time.Now()
conn, err := net.DialTimeout("tcp", task.GetData(), time.Second*10) conn, err := net.DialTimeout("tcp", task.GetData(), time.Second*10)
if err == nil { if err == nil {
@ -221,6 +221,17 @@ func doTask(task *pb.Task) {
} else { } else {
result.Data = err.Error() result.Data = err.Error()
} }
case model.TaskTypeCommand:
startedAt := time.Now()
cmd := exec.Command(task.GetData())
output, err := cmd.Output()
result.Delay = float32(time.Now().Sub(startedAt).Seconds())
if err != nil {
result.Data = fmt.Sprintf("%s\n%s", string(output), err.Error())
} else {
result.Data = string(output)
result.Successful = true
}
default: default:
log.Printf("Unknown action: %v", task) log.Printf("Unknown action: %v", task)
} }

View File

@ -22,7 +22,7 @@ func ServeWeb(port uint) {
r.Use(mygin.RecordPath) r.Use(mygin.RecordPath)
r.SetFuncMap(template.FuncMap{ r.SetFuncMap(template.FuncMap{
"tf": func(t time.Time) string { "tf": func(t time.Time) string {
return t.Format("2006年1月2号") return t.Format("2006年1月2号 15:04:05")
}, },
"safe": func(s string) template.HTML { "safe": func(s string) template.HTML {
return template.HTML(s) return template.HTML(s)

View File

@ -9,10 +9,12 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/naiba/com" "github.com/robfig/cron/v3"
"github.com/naiba/nezha/model" "github.com/naiba/nezha/model"
"github.com/naiba/nezha/pkg/mygin" "github.com/naiba/nezha/pkg/mygin"
"github.com/naiba/nezha/pkg/utils"
pb "github.com/naiba/nezha/proto"
"github.com/naiba/nezha/service/alertmanager" "github.com/naiba/nezha/service/alertmanager"
"github.com/naiba/nezha/service/dao" "github.com/naiba/nezha/service/dao"
) )
@ -34,6 +36,7 @@ func (ma *memberAPI) serve() {
mr.POST("/logout", ma.logout) mr.POST("/logout", ma.logout)
mr.POST("/server", ma.addOrEditServer) mr.POST("/server", ma.addOrEditServer)
mr.POST("/monitor", ma.addOrEditMonitor) mr.POST("/monitor", ma.addOrEditMonitor)
mr.POST("/cron", ma.addOrEditCron)
mr.POST("/notification", ma.addOrEditNotification) mr.POST("/notification", ma.addOrEditNotification)
mr.POST("/alert-rule", ma.addOrEditAlertRule) mr.POST("/alert-rule", ma.addOrEditAlertRule)
mr.POST("/setting", ma.updateSetting) mr.POST("/setting", ma.updateSetting)
@ -70,6 +73,17 @@ func (ma *memberAPI) delete(c *gin.Context) {
if err == nil { if err == nil {
err = dao.DB.Delete(&model.MonitorHistory{}, "monitor_id = ?", id).Error err = dao.DB.Delete(&model.MonitorHistory{}, "monitor_id = ?", id).Error
} }
case "cron":
err = dao.DB.Delete(&model.Cron{}, "id = ?", id).Error
if err == nil {
dao.CronLock.RLock()
defer dao.CronLock.RUnlock()
if dao.Crons[id].CronID != 0 {
dao.Cron.Remove(dao.Crons[id].CronID)
}
delete(dao.Crons, id)
}
case "alert-rule": case "alert-rule":
err = dao.DB.Delete(&model.AlertRule{}, "id = ?", id).Error err = dao.DB.Delete(&model.AlertRule{}, "id = ?", id).Error
if err == nil { if err == nil {
@ -109,7 +123,7 @@ func (ma *memberAPI) addOrEditServer(c *gin.Context) {
s.ID = sf.ID s.ID = sf.ID
s.Tag = sf.Tag s.Tag = sf.Tag
if sf.ID == 0 { if sf.ID == 0 {
s.Secret = com.MD5(fmt.Sprintf("%s%s%d", time.Now(), sf.Name, admin.ID)) s.Secret = utils.MD5(fmt.Sprintf("%s%s%d", time.Now(), sf.Name, admin.ID))
s.Secret = s.Secret[:10] s.Secret = s.Secret[:10]
err = dao.DB.Create(&s).Error err = dao.DB.Create(&s).Error
} else { } else {
@ -179,6 +193,72 @@ func (ma *memberAPI) addOrEditMonitor(c *gin.Context) {
}) })
} }
type cronForm struct {
ID uint64
Name string
Scheduler string
Command string
ServersRaw string
PushSuccessful string
}
func (ma *memberAPI) addOrEditCron(c *gin.Context) {
var cf cronForm
var cr model.Cron
err := c.ShouldBindJSON(&cf)
if err == nil {
cr.Name = cf.Name
cr.Scheduler = cf.Scheduler
cr.Command = cf.Command
cr.ServersRaw = cf.ServersRaw
cr.PushSuccessful = cf.PushSuccessful == "on"
cr.ID = cf.ID
err = json.Unmarshal([]byte(cf.ServersRaw), &cr.Servers)
}
if err == nil {
_, err = cron.ParseStandard(cr.Scheduler)
}
if err == nil {
if cf.ID == 0 {
err = dao.DB.Create(&cr).Error
} else {
err = dao.DB.Save(&cr).Error
}
}
if err != nil {
c.JSON(http.StatusOK, model.Response{
Code: http.StatusBadRequest,
Message: fmt.Sprintf("请求错误:%s", err),
})
return
}
if cr.CronID != 0 {
dao.Cron.Remove(cr.CronID)
}
cr.CronID, err = dao.Cron.AddFunc(cr.Scheduler, func() {
dao.ServerLock.RLock()
defer dao.ServerLock.RUnlock()
for j := 0; j < len(cr.Servers); j++ {
if dao.ServerList[cr.Servers[j]].TaskStream != nil {
dao.ServerList[cr.Servers[j]].TaskStream.Send(&pb.Task{
Id: cr.ID,
Data: cr.Command,
Type: model.TaskTypeCommand,
})
} else {
alertmanager.SendNotification(fmt.Sprintf("计划任务:%s服务器%d 离线,无法执行。", cr.Name, cr.Servers[j]))
}
}
})
c.JSON(http.StatusOK, model.Response{
Code: http.StatusOK,
})
}
type notificationForm struct { type notificationForm struct {
ID uint64 ID uint64
Name string Name string

View File

@ -24,6 +24,7 @@ func (mp *memberPage) serve() {
})) }))
mr.GET("/server", mp.server) mr.GET("/server", mp.server)
mr.GET("/monitor", mp.monitor) mr.GET("/monitor", mp.monitor)
mr.GET("/cron", mp.cron)
mr.GET("/notification", mp.notification) mr.GET("/notification", mp.notification)
mr.GET("/setting", mp.setting) mr.GET("/setting", mp.setting)
} }
@ -46,6 +47,15 @@ func (mp *memberPage) monitor(c *gin.Context) {
})) }))
} }
func (mp *memberPage) cron(c *gin.Context) {
var crons []model.Cron
dao.DB.Find(&crons)
c.HTML(http.StatusOK, "dashboard/cron", mygin.CommonEnvironment(c, gin.H{
"Title": "计划任务",
"Crons": crons,
}))
}
func (mp *memberPage) notification(c *gin.Context) { func (mp *memberPage) notification(c *gin.Context) {
var nf []model.Notification var nf []model.Notification
dao.DB.Find(&nf) dao.DB.Find(&nf)

View File

@ -6,14 +6,13 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/naiba/com"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
GitHubAPI "github.com/google/go-github/github" GitHubAPI "github.com/google/go-github/github"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"github.com/naiba/nezha/model" "github.com/naiba/nezha/model"
"github.com/naiba/nezha/pkg/mygin" "github.com/naiba/nezha/pkg/mygin"
"github.com/naiba/nezha/pkg/utils"
"github.com/naiba/nezha/service/dao" "github.com/naiba/nezha/service/dao"
) )
@ -28,7 +27,7 @@ func (oa *oauth2controller) serve() {
} }
func (oa *oauth2controller) login(c *gin.Context) { func (oa *oauth2controller) login(c *gin.Context) {
state := com.RandomString(6) state := utils.RandStringBytesMaskImprSrcUnsafe(6)
dao.Cache.Set(fmt.Sprintf("%s%s", model.CtxKeyOauth2State, c.ClientIP()), state, 0) dao.Cache.Set(fmt.Sprintf("%s%s", model.CtxKeyOauth2State, c.ClientIP()), state, 0)
url := oa.oauth2Config.AuthCodeURL(state, oauth2.AccessTypeOnline) url := oa.oauth2Config.AuthCodeURL(state, oauth2.AccessTypeOnline)
c.Redirect(http.StatusFound, url) c.Redirect(http.StatusFound, url)

View File

@ -1,6 +1,7 @@
package main package main
import ( import (
"fmt"
"time" "time"
"github.com/patrickmn/go-cache" "github.com/patrickmn/go-cache"
@ -10,13 +11,13 @@ import (
"github.com/naiba/nezha/cmd/dashboard/controller" "github.com/naiba/nezha/cmd/dashboard/controller"
"github.com/naiba/nezha/cmd/dashboard/rpc" "github.com/naiba/nezha/cmd/dashboard/rpc"
"github.com/naiba/nezha/model" "github.com/naiba/nezha/model"
pb "github.com/naiba/nezha/proto"
"github.com/naiba/nezha/service/alertmanager" "github.com/naiba/nezha/service/alertmanager"
"github.com/naiba/nezha/service/dao" "github.com/naiba/nezha/service/dao"
) )
func init() { func init() {
var err error var err error
dao.ServerList = make(map[uint64]*model.Server)
dao.Conf = &model.Config{} dao.Conf = &model.Config{}
err = dao.Conf.Read("data/config.yaml") err = dao.Conf.Read("data/config.yaml")
if err != nil { if err != nil {
@ -30,14 +31,26 @@ func init() {
dao.DB = dao.DB.Debug() dao.DB = dao.DB.Debug()
} }
dao.Cache = cache.New(5*time.Minute, 10*time.Minute) dao.Cache = cache.New(5*time.Minute, 10*time.Minute)
initDB() initSystem()
} }
func initDB() { func initSystem() {
dao.DB.AutoMigrate(model.Server{}, model.User{}, dao.DB.AutoMigrate(model.Server{}, model.User{},
model.Notification{}, model.AlertRule{}, model.Monitor{}, model.Notification{}, model.AlertRule{}, model.Monitor{},
model.MonitorHistory{}) model.MonitorHistory{}, model.Cron{})
// load cache
loadServers() //加载服务器列表
loadCrons() //加载计划任务
// 清理旧数据
dao.Cron.AddFunc("* 3 * * *", cleanMonitorHistory)
}
func cleanMonitorHistory() {
dao.DB.Delete(&model.MonitorHistory{}, "created_at < ?", time.Now().AddDate(0, -1, 0))
}
func loadServers() {
var servers []model.Server var servers []model.Server
dao.DB.Find(&servers) dao.DB.Find(&servers)
for _, s := range servers { for _, s := range servers {
@ -49,6 +62,35 @@ func initDB() {
dao.ReSortServer() dao.ReSortServer()
} }
func loadCrons() {
var crons []model.Cron
dao.DB.Find(&crons)
var err error
for i := 0; i < len(crons); i++ {
cr := crons[i]
cr.CronID, err = dao.Cron.AddFunc(cr.Scheduler, func() {
dao.ServerLock.RLock()
defer dao.ServerLock.RUnlock()
for j := 0; j < len(cr.Servers); j++ {
if dao.ServerList[cr.Servers[j]].TaskStream != nil {
dao.ServerList[cr.Servers[j]].TaskStream.Send(&pb.Task{
Id: cr.ID,
Data: cr.Command,
Type: model.TaskTypeCommand,
})
} else {
alertmanager.SendNotification(fmt.Sprintf("计划任务:%s服务器%d 离线,无法执行。", cr.Name, cr.Servers[j]))
}
}
})
if err != nil {
panic(err)
}
dao.Crons[cr.ID] = &cr
}
dao.Cron.Start()
}
func main() { func main() {
go controller.ServeWeb(dao.Conf.HTTPPort) go controller.ServeWeb(dao.Conf.HTTPPort)
go rpc.ServeRPC(5555) go rpc.ServeRPC(5555)

2
go.mod
View File

@ -12,11 +12,11 @@ require (
github.com/golang/protobuf v1.4.2 github.com/golang/protobuf v1.4.2
github.com/google/go-github v17.0.0+incompatible github.com/google/go-github v17.0.0+incompatible
github.com/gorilla/websocket v1.4.2 github.com/gorilla/websocket v1.4.2
github.com/naiba/com v0.0.0-20191104074000-318339dc72a5
github.com/onsi/ginkgo v1.7.0 // indirect github.com/onsi/ginkgo v1.7.0 // indirect
github.com/onsi/gomega v1.4.3 // indirect github.com/onsi/gomega v1.4.3 // indirect
github.com/p14yground/go-github-selfupdate v0.0.0-20201212172144-81a03b17860d github.com/p14yground/go-github-selfupdate v0.0.0-20201212172144-81a03b17860d
github.com/patrickmn/go-cache v2.1.0+incompatible github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v3 v3.20.11 github.com/shirou/gopsutil/v3 v3.20.11
github.com/spf13/cobra v0.0.5 github.com/spf13/cobra v0.0.5
github.com/spf13/viper v1.7.1 github.com/spf13/viper v1.7.1

4
go.sum
View File

@ -256,8 +256,6 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/naiba/com v0.0.0-20191104074000-318339dc72a5 h1:NYtQRl/K+QXUTLdamg3nHkdI1qGXhN+4B8ZdGktAaVo=
github.com/naiba/com v0.0.0-20191104074000-318339dc72a5/go.mod h1:gU4KduAcIb9RnoirznFwMcPjeSyYQhvXYL9NWEWUMYI=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
@ -287,6 +285,8 @@ github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=

27
model/cron.go Normal file
View File

@ -0,0 +1,27 @@
package model
import (
"encoding/json"
"time"
"github.com/robfig/cron/v3"
"gorm.io/gorm"
)
type Cron struct {
Common
Name string
Scheduler string //分钟 小时 天 月 星期
Command string
Servers []uint64 `gorm:"-"`
PushSuccessful bool // 推送成功的通知
LastExecutedAt time.Time // 最后一次执行时间
LastResult bool // 最后一次执行结果
CronID cron.EntryID `gorn:"-"`
ServersRaw string
}
func (c *Cron) AfterFind(tx *gorm.DB) error {
return json.Unmarshal([]byte(c.ServersRaw), &c.Servers)
}

View File

@ -6,9 +6,10 @@ import (
const ( const (
_ = iota _ = iota
MonitorTypeHTTPGET TaskTypeHTTPGET
MonitorTypeICMPPing TaskTypeICMPPing
MonitorTypeTCPPing TaskTypeTCPPing
TaskTypeCommand
) )
type Monitor struct { type Monitor struct {

View File

@ -5,7 +5,8 @@ import (
"time" "time"
"github.com/google/go-github/github" "github.com/google/go-github/github"
"github.com/naiba/com"
"github.com/naiba/nezha/pkg/utils"
) )
type User struct { type User struct {
@ -44,6 +45,6 @@ func NewUserFromGitHub(gu *github.User) User {
} }
func (u *User) IssueNewToken() { func (u *User) IssueNewToken() {
u.Token = com.MD5(fmt.Sprintf("%d%d%s", time.Now().UnixNano(), u.ID, u.Login)) u.Token = utils.MD5(fmt.Sprintf("%d%d%s", time.Now().UnixNano(), u.ID, u.Login))
u.TokenExpired = time.Now().AddDate(0, 2, 0) u.TokenExpired = time.Now().AddDate(0, 2, 0)
} }

View File

@ -15,6 +15,7 @@ var adminPage = map[string]bool{
"/monitor": true, "/monitor": true,
"/setting": true, "/setting": true,
"/notification": true, "/notification": true,
"/cron": true,
} }
func CommonEnvironment(c *gin.Context, data map[string]interface{}) gin.H { func CommonEnvironment(c *gin.Context, data map[string]interface{}) gin.H {

42
pkg/utils/utils.go Normal file
View File

@ -0,0 +1,42 @@
package utils
import (
"crypto/md5"
"encoding/hex"
"math/rand"
"time"
"unsafe"
)
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
const (
letterIdxBits = 6 // 6 bits to represent a letter index
letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
letterIdxMax = 63 / letterIdxBits // # of letter indices fitting in 63 bits
)
func RandStringBytesMaskImprSrcUnsafe(n int) string {
var src = rand.NewSource(time.Now().UnixNano())
b := make([]byte, n)
// A src.Int63() generates 63 random bits, enough for letterIdxMax characters!
for i, cache, remain := n-1, src.Int63(), letterIdxMax; i >= 0; {
if remain == 0 {
cache, remain = src.Int63(), letterIdxMax
}
if idx := int(cache & letterIdxMask); idx < len(letterBytes) {
b[i] = letterBytes[idx]
i--
}
cache >>= letterIdxBits
remain--
}
return *(*string)(unsafe.Pointer(&b))
}
func MD5(plantext string) string {
hash := md5.New()
hash.Write([]byte(plantext))
return hex.EncodeToString(hash.Sum(nil))
}

View File

@ -129,6 +129,23 @@ function addOrEditMonitor(monitor) {
showFormModal('.monitor.modal', '#monitorForm', '/api/monitor') showFormModal('.monitor.modal', '#monitorForm', '/api/monitor')
} }
function addOrEditCron(cron) {
const modal = $('.cron.modal')
modal.children('.header').text((cron ? '修改' : '添加') + '计划任务')
modal.find('.positive.button').html(cron ? '修改<i class="edit icon"></i>' : '添加<i class="add icon"></i>')
modal.find('input[name=ID]').val(cron ? cron.ID : null)
modal.find('input[name=Name]').val(cron ? cron.Name : null)
modal.find('input[name=Scheduler]').val(cron ? cron.Scheduler : null)
modal.find('input[name=ServersRaw]').val(cron ? cron.ServersRaw : '[]')
modal.find('textarea[name=Command]').val(cron ? cron.Command : null)
if (cron && cron.PushSuccessful) {
modal.find('.ui.push-successful.checkbox').checkbox('set checked')
} else {
modal.find('.ui.push-successful.checkbox').checkbox('set unchecked')
}
showFormModal('.cron.modal', '#cronForm', '/api/cron')
}
function deleteRequest(api) { function deleteRequest(api) {
$.ajax({ $.ajax({
url: api, url: api,

View File

@ -8,7 +8,7 @@
<script src="https://cdn.jsdelivr.net/npm/semantic-ui@2.4.1/dist/semantic.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/semantic-ui@2.4.1/dist/semantic.min.js"></script>
<script src="/static/semantic-ui-alerts.min.js"></script> <script src="/static/semantic-ui-alerts.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.min.js"></script>
<script src="/static/main.js?v202101132218"></script> <script src="/static/main.js?v202101190958"></script>
</body> </body>
</html> </html>

View File

@ -7,6 +7,7 @@
{{if .IsAdminPage}} {{if .IsAdminPage}}
<a class="item{{if eq .MatchedPath " /server"}} active{{end}}" href="/server">服务器</a> <a class="item{{if eq .MatchedPath " /server"}} active{{end}}" href="/server">服务器</a>
<a class="item{{if eq .MatchedPath " /monitor"}} active{{end}}" href="/monitor">服务监控</a> <a class="item{{if eq .MatchedPath " /monitor"}} active{{end}}" href="/monitor">服务监控</a>
<a class="item{{if eq .MatchedPath " /cron"}} active{{end}}" href="/cron">计划任务</a>
<a class="item{{if eq .MatchedPath " /notification"}} active{{end}}" href="/notification">通知</a> <a class="item{{if eq .MatchedPath " /notification"}} active{{end}}" href="/notification">通知</a>
<a class="item{{if eq .MatchedPath " /setting"}} active{{end}}" href="/setting">设置</a> <a class="item{{if eq .MatchedPath " /setting"}} active{{end}}" href="/setting">设置</a>
{{else}} {{else}}

View File

@ -0,0 +1,45 @@
{{define "component/cron"}}
<div class="ui tiny cron modal transition hidden">
<div class="header">添加计划任务</div>
<div class="content">
<form id="cronForm" class="ui form">
<input type="hidden" name="ID">
<div class="field">
<label>备注</label>
<input type="text" name="Name" placeholder="备份">
</div>
<div class="field">
<label>计划</label>
<input type="text" name="Scheduler" placeholder="* * 3 * *每天3点">
</div>
<div class="field">
<label>命令</label>
<textarea name="Command"></textarea>
</div>
<div class="field">
<label>执行服务器ID列表</label>
<input type="text" name="ServersRaw" placeholder="[10,7,19]">
</div>
<div class="field">
<div class="ui push-successful checkbox">
<input name="PushSuccessful" type="checkbox" tabindex="0" class="hidden">
<label>推送成功的消息</label>
</div>
</div>
</form>
<div class="ui warning message">
<p>
计划的格式为:<code>* * * * *</code> 分 时 天 月 星期,详情见 <a
href="https://pkg.go.dev/github.com/robfig/cron/v3#hdr-CRON_Expression_Format" target="_blank">计划表达式格式</a><br>
命令Shell 命令,就像写脚本一样就可以,如果遇到 xxx 命令找不到,可能是 <code>PATH</code> 环境变量的问题,<code>source ~/.bashrc</code>
或者使用绝对路径执行。
</p>
</div>
</div>
<div class=" actions">
<div class="ui negative button">取消</div>
<button class="ui positive right labeled icon button">确认<i class="checkmark icon"></i>
</button>
</div>
</div>
{{end}}

View File

@ -0,0 +1,57 @@
{{define "dashboard/cron"}}
{{template "common/header" .}}
{{template "common/menu" .}}
<div class="nb-container">
<div class="ui container">
<div class="ui grid">
<div class="right floated right aligned twelve wide column">
<button class="ui right labeled positive icon button" onclick="addOrEditCron()"><i class="add icon"></i>
添加计划任务
</button>
</div>
</div>
<table class="ui very basic table">
<thead>
<tr>
<th>ID</th>
<th>备注</th>
<th>计划</th>
<th>命令</th>
<th>成功推送</th>
<th>执行者</th>
<th>最后执行</th>
<th>最后结果</th>
<th>管理</th>
</tr>
</thead>
<tbody>
{{range $cron := .Crons}}
<tr>
<td>{{$cron.ID}}</td>
<td>{{$cron.Name}}</td>
<td>{{$cron.Scheduler}}</td>
<td>{{$cron.Command}}</td>
<td>{{$cron.PushSuccessful}}</td>
<td>{{$cron.ServersRaw}}</td>
<td>{{$cron.LastExecutedAt|tf}}</td>
<td>{{$cron.LastResult}}</td>
<td>
<div class="ui mini icon buttons">
<button class="ui button" onclick="addOrEditCron({{$cron}})">
<i class="edit icon"></i>
</button>
<button class="ui button"
onclick="showConfirm('删除计划任务','确认删除此计划任务?',deleteRequest,'/api/cron/'+{{$cron.ID}})">
<i class="delete icon"></i>
</button>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{template "component/cron"}}
{{template "common/footer" .}}
{{end}}

View File

@ -3,8 +3,10 @@ package dao
import ( import (
"sort" "sort"
"sync" "sync"
"time"
"github.com/patrickmn/go-cache" "github.com/patrickmn/go-cache"
"github.com/robfig/cron/v3"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/naiba/nezha/model" "github.com/naiba/nezha/model"
@ -21,13 +23,29 @@ var Cache *cache.Cache
var DB *gorm.DB var DB *gorm.DB
// 服务器监控、状态相关
var ServerList map[uint64]*model.Server var ServerList map[uint64]*model.Server
var ServerLock sync.RWMutex var ServerLock sync.RWMutex
var SortedServerList []*model.Server var SortedServerList []*model.Server
var SortedServerLock sync.RWMutex var SortedServerLock sync.RWMutex
var Version = "v0.2.6" // 计划任务相关
var CronLock sync.RWMutex
var Crons map[uint64]*model.Cron
var Cron *cron.Cron
var Version = "v0.3.0"
func init() {
shanghai, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
panic(err)
}
Cron = cron.New(cron.WithLocation(shanghai))
Crons = make(map[uint64]*model.Cron)
ServerList = make(map[uint64]*model.Server)
}
func ReSortServer() { func ReSortServer() {
ServerLock.RLock() ServerLock.RLock()

View File

@ -18,10 +18,11 @@ type NezhaHandler struct {
func (s *NezhaHandler) ReportTask(c context.Context, r *pb.TaskResult) (*pb.Receipt, error) { func (s *NezhaHandler) ReportTask(c context.Context, r *pb.TaskResult) (*pb.Receipt, error) {
var err error var err error
if _, err = s.Auth.Check(c); err != nil { var clientID uint64
if clientID, err = s.Auth.Check(c); err != nil {
return nil, err return nil, err
} }
if r.GetType() == model.MonitorTypeHTTPGET { if r.GetType() == model.TaskTypeHTTPGET {
// SSL 证书报警 // SSL 证书报警
var errMsg string var errMsg string
if strings.HasPrefix(r.GetData(), "SSL证书错误") { if strings.HasPrefix(r.GetData(), "SSL证书错误") {
@ -54,10 +55,27 @@ func (s *NezhaHandler) ReportTask(c context.Context, r *pb.TaskResult) (*pb.Rece
alertmanager.SendNotification(fmt.Sprintf("服务监控:%s %s", monitor.Name, errMsg)) alertmanager.SendNotification(fmt.Sprintf("服务监控:%s %s", monitor.Name, errMsg))
} }
} }
// 存入历史记录 if r.GetType() == model.TaskTypeCommand {
mh := model.PB2MonitorHistory(r) // 处理上报的计划任务
if err := dao.DB.Create(&mh).Error; err != nil { dao.CronLock.RLock()
return nil, err cr := dao.Crons[r.GetId()]
dao.CronLock.RUnlock()
if cr.PushSuccessful && r.GetSuccessful() {
alertmanager.SendNotification(fmt.Sprintf("成功计划任务:%s ,服务器:%d日志\n%s", cr.Name, clientID, r.GetData()))
}
if !r.GetSuccessful() {
alertmanager.SendNotification(fmt.Sprintf("失败计划任务:%s ,服务器:%d日志\n%s", cr.Name, clientID, r.GetData()))
}
dao.DB.Model(cr).Updates(model.Cron{
LastExecutedAt: time.Now().Add(time.Second * -1 * time.Duration(r.GetDelay())),
LastResult: r.GetSuccessful(),
})
} else {
// 存入历史记录
mh := model.PB2MonitorHistory(r)
if err := dao.DB.Create(&mh).Error; err != nil {
return nil, err
}
} }
return &pb.Receipt{Proced: true}, nil return &pb.Receipt{Proced: true}, nil
} }