feat(notification): 添加通知方式

This commit is contained in:
naiba 2020-12-19 22:14:36 +08:00
parent b118958f35
commit d19de0edc2
16 changed files with 391 additions and 58 deletions

View File

@ -24,7 +24,8 @@
sudo ./nezha.sh sudo ./nezha.sh
``` ```
## 主题自定义 ## 使用说明
### 自定义 CSS
- 默认主题更改进度条颜色示例 - 默认主题更改进度条颜色示例
@ -42,6 +43,10 @@
} }
``` ```
### 通知
## 常见问题 ## 常见问题
### 数据备份恢复 ### 数据备份恢复

View File

@ -1,6 +1,7 @@
package controller package controller
import ( import (
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
@ -30,8 +31,10 @@ 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("/notification", ma.addOrEditNotification)
mr.POST("/setting", ma.updateSetting) mr.POST("/setting", ma.updateSetting)
mr.DELETE("/server/:id", ma.delete) mr.DELETE("/server/:id", ma.delete)
mr.DELETE("/notification/:id", ma.deleteNotification)
} }
func (ma *memberAPI) delete(c *gin.Context) { func (ma *memberAPI) delete(c *gin.Context) {
@ -58,6 +61,27 @@ func (ma *memberAPI) delete(c *gin.Context) {
}) })
} }
func (ma *memberAPI) deleteNotification(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if id < 1 {
c.JSON(http.StatusOK, model.Response{
Code: http.StatusBadRequest,
Message: "错误的 Notification ID",
})
return
}
if err := dao.DB.Delete(&model.Notification{}, "id = ?", id).Error; err != nil {
c.JSON(http.StatusOK, model.Response{
Code: http.StatusBadRequest,
Message: fmt.Sprintf("数据库错误:%s", err),
})
return
}
c.JSON(http.StatusOK, model.Response{
Code: http.StatusOK,
})
}
type serverForm struct { type serverForm struct {
ID uint64 ID uint64
Name string `binding:"required"` Name string `binding:"required"`
@ -96,6 +120,51 @@ func (ma *memberAPI) addOrEditServer(c *gin.Context) {
}) })
} }
type notificationForm struct {
ID uint64
Name string
URL string
RequestMethod int
RequestType int
RequestBody string
VerifySSL string
}
func (ma *memberAPI) addOrEditNotification(c *gin.Context) {
var nf notificationForm
var n model.Notification
err := c.ShouldBindJSON(&nf)
if err == nil {
var data map[string]string
err = json.Unmarshal([]byte(nf.RequestBody), &data)
}
if err == nil {
n.Name = nf.Name
n.RequestMethod = nf.RequestMethod
n.RequestType = nf.RequestType
n.RequestBody = nf.RequestBody
n.URL = nf.URL
verifySSL := nf.VerifySSL == "on"
n.VerifySSL = &verifySSL
n.ID = nf.ID
if n.ID == 0 {
err = dao.DB.Create(&n).Error
} else {
err = dao.DB.Save(&n).Error
}
}
if err != nil {
c.JSON(http.StatusOK, model.Response{
Code: http.StatusBadRequest,
Message: fmt.Sprintf("请求错误:%s", err),
})
return
}
c.JSON(http.StatusOK, model.Response{
Code: http.StatusOK,
})
}
type logoutForm struct { type logoutForm struct {
ID uint64 ID uint64
} }

View File

@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/naiba/nezha/model"
"github.com/naiba/nezha/pkg/mygin" "github.com/naiba/nezha/pkg/mygin"
"github.com/naiba/nezha/service/dao" "github.com/naiba/nezha/service/dao"
) )
@ -22,6 +23,7 @@ func (mp *memberPage) serve() {
Redirect: "/login", Redirect: "/login",
})) }))
mr.GET("/server", mp.server) mr.GET("/server", mp.server)
mr.GET("/notification", mp.notification)
mr.GET("/setting", mp.setting) mr.GET("/setting", mp.setting)
} }
@ -34,6 +36,15 @@ func (mp *memberPage) server(c *gin.Context) {
})) }))
} }
func (mp *memberPage) notification(c *gin.Context) {
var nf []model.Notification
dao.DB.Find(&nf)
c.HTML(http.StatusOK, "dashboard/notification", mygin.CommonEnvironment(c, gin.H{
"Title": "通知管理",
"Notifications": nf,
}))
}
func (mp *memberPage) setting(c *gin.Context) { func (mp *memberPage) setting(c *gin.Context) {
c.HTML(http.StatusOK, "dashboard/setting", mygin.CommonEnvironment(c, gin.H{ c.HTML(http.StatusOK, "dashboard/setting", mygin.CommonEnvironment(c, gin.H{
"Title": "系统设置", "Title": "系统设置",

View File

@ -34,7 +34,7 @@ func init() {
} }
func initDB() { func initDB() {
dao.DB.AutoMigrate(model.Server{}, model.User{}) dao.DB.AutoMigrate(model.Server{}, model.User{}, model.Notification{})
// load cache // load cache
var servers []model.Server var servers []model.Server
dao.DB.Find(&servers) dao.DB.Find(&servers)

100
model/notification.go Normal file
View File

@ -0,0 +1,100 @@
package model
import (
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
const (
_ = iota
NotificationRequestTypeJSON
NotificationRequestTypeForm
)
const (
_ = iota
NotificationRequestMethodGET
NotificationRequestMethodPOST
)
type NotificatonSender struct {
Rule *Rule
Server *Server
State *State
}
type Notification struct {
Common
Name string
URL string
RequestMethod int
RequestType int
RequestBody string `gorm:"type:longtext" `
VerifySSL *bool
}
func (n *Notification) Send(sender *NotificatonSender, message string) {
var verifySSL bool
if n.VerifySSL != nil && *n.VerifySSL {
verifySSL = true
}
var err error
transCfg := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: verifySSL},
}
client := &http.Client{Transport: transCfg, Timeout: time.Minute * 10}
var reqURL *url.URL
reqURL, err = url.Parse(n.URL)
var data map[string]string
if err == nil && (n.RequestMethod == NotificationRequestMethodGET || n.RequestType == NotificationRequestTypeForm) {
err = json.Unmarshal([]byte(n.RequestBody), &data)
}
if err == nil {
if n.RequestMethod == NotificationRequestMethodGET {
for k, v := range data {
reqURL.Query().Set(k, replaceParamsInString(v, sender))
}
client.Get(reqURL.String())
} else {
if n.RequestType == NotificationRequestTypeForm {
params := url.Values{}
for k, v := range data {
params.Add(k, replaceParamsInString(v, sender))
}
client.PostForm(reqURL.String(), params)
} else {
jsonValue := replaceParamsInJSON(n.RequestBody, sender)
if err == nil {
client.Post(reqURL.String(), "application/json", strings.NewReader(jsonValue))
}
}
}
}
}
func replaceParamsInString(str string, sender *NotificatonSender) string {
str = strings.ReplaceAll(str, "#CPU#", fmt.Sprintf("%2f%%", sender.State.CPU))
return str
}
func replaceParamsInJSON(str string, sender *NotificatonSender) string {
str = strings.ReplaceAll(str, "#CPU#", fmt.Sprintf("%2f%%", sender.State.CPU))
return str
}
func jsonEscape(raw interface{}) string {
b, _ := json.Marshal(raw)
strb := string(b)
if strings.HasPrefix(strb, "\"") {
return strb[1 : len(strb)-1]
}
return strb
}

10
model/rule.go Normal file
View File

@ -0,0 +1,10 @@
package model
type Rule struct {
Common
Name string
Type string // 指标类型cpu、memory、swap、disk、net_in、net_out、net_all、transfer_in、transfer_out、transfer_all、offline
Min uint64 // 最小阈值
Max uint64 // 最大阈值
Duration uint64 // 持续时间
}

View File

@ -2,6 +2,7 @@ package model
import ( import (
"fmt" "fmt"
"html/template"
"time" "time"
pb "github.com/naiba/nezha/proto" pb "github.com/naiba/nezha/proto"
@ -21,6 +22,6 @@ type Server struct {
StreamClose chan<- error `gorm:"-" json:"-"` StreamClose chan<- error `gorm:"-" json:"-"`
} }
func (s Server) Marshal() string { func (s Server) Marshal() template.JS {
return fmt.Sprintf(`{"ID":%d,"Name":"%s","Secret":"%s"}`, s.ID, s.Name, s.Secret) return template.JS(fmt.Sprintf(`{"ID":%d,"Name":"%s","Secret":"%s"}`, s.ID, s.Name, s.Secret))
} }

View File

@ -42,7 +42,7 @@ function showFormModal(modelSelector, formID, URL, getData) {
form.children('.message').remove() form.children('.message').remove()
btn.toggleClass('loading') btn.toggleClass('loading')
const data = getData ? getData() : $(formID).serializeArray().reduce(function (obj, item) { const data = getData ? getData() : $(formID).serializeArray().reduce(function (obj, item) {
obj[item.name] = (item.name.endsWith('_id') || item.name === 'id') ? parseInt(item.value) : item.value; obj[item.name] = (item.name.endsWith('_id') || item.name === 'id' || item.name === 'ID' || item.name === 'RequestType' || item.name === 'RequestMethod') ? parseInt(item.value) : item.value;
return obj; return obj;
}, {}); }, {});
$.post(URL, JSON.stringify(data)).done(function (resp) { $.post(URL, JSON.stringify(data)).done(function (resp) {
@ -70,11 +70,26 @@ function showFormModal(modelSelector, formID, URL, getData) {
}).modal('show') }).modal('show')
} }
function addOrEditNotification(notification) {
const modal = $('.notification.modal')
modal.children('.header').text((notification ? '修改' : '添加') + '通知方式')
modal.find('.positive.button').html(notification ? '修改<i class="edit icon"></i>' : '添加<i class="add icon"></i>')
modal.find('input[name=ID]').val(notification ? notification.ID : null)
modal.find('input[name=Name]').val(notification ? notification.Name : null)
modal.find('input[name=URL]').val(notification ? notification.URL : null)
modal.find('textarea[name=RequestBody]').val(notification ? notification.RequestBody : null)
modal.find('select[name=RequestMethod]').val(notification ? notification.RequestMethod : 1)
modal.find('select[name=RequestType]').val(notification ? notification.RequestType : 1)
if (notification && notification.VerifySSL) {
modal.find('.ui.checkbox').checkbox('set checked')
} else {
modal.find('.ui.checkbox').checkbox('set unchecked')
}
showFormModal('.notification.modal', '#notificationForm', '/api/notification')
}
function addOrEditServer(server) { function addOrEditServer(server) {
const modal = $('.server.modal') const modal = $('.server.modal')
if (server) {
server = JSON.parse(server)
}
modal.children('.header').text((server ? '修改' : '添加') + '服务器') modal.children('.header').text((server ? '修改' : '添加') + '服务器')
modal.find('.positive.button').html(server ? '修改<i class="edit icon"></i>' : '添加<i class="add icon"></i>') modal.find('.positive.button').html(server ? '修改<i class="edit icon"></i>' : '添加<i class="add icon"></i>')
modal.find('input[name=id]').val(server ? server.ID : null) modal.find('input[name=id]').val(server ? server.ID : null)

View File

@ -1,21 +1,3 @@
/* Dark mode */
#darkmodeButton {
background-color: black;
width: 3rem;
height: 3rem;
position: fixed;
bottom: 2rem;
right: 0.5rem;
z-index: 9999;
border-radius: 50%;
outline: 0;
}
body.dark #darkmodeButton {
background-color: white;
}
body.dark { body.dark {
background: #263236; background: #263236;
color: #aaa; color: #aaa;
@ -38,7 +20,8 @@ body.dark .panel {
color: #aaa; color: #aaa;
} }
body.dark .panel h3, body.dark .panel span { body.dark .panel h3,
body.dark .panel span {
color: #aaa; color: #aaa;
} }

View File

@ -4,10 +4,11 @@
<div class="item"> <div class="item">
<img src="/static/logo.png"> <img src="/static/logo.png">
</div> </div>
<a class="item{{if eq .MatchedPath "/"}} active{{end}}" href="/">首页</a> <a class="item{{if eq .MatchedPath " /"}} active{{end}}" href="/">首页</a>
{{if .Admin}} {{if .Admin}}
<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 "/setting"}} active{{end}}" href="/setting">设置</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>
{{end}} {{end}}
<div class="right menu"> <div class="right menu">
<a class="item" href="https://github.com/naiba/nezha/issues" target="_blank">反馈</a> <a class="item" href="https://github.com/naiba/nezha/issues" target="_blank">反馈</a>

View File

@ -0,0 +1,47 @@
{{define "component/notification"}}
<div class="ui tiny notification modal transition hidden">
<div class="header">添加通知方式</div>
<div class="content">
<form id="notificationForm" class="ui form">
<input type="hidden" name="ID">
<div class="field">
<label>备注</label>
<input type="text" name="Name">
</div>
<div class="field">
<label>URL</label>
<input type="text" name="URL">
</div>
<div class="field">
<label>请求方式</label>
<select name="RequestMethod" class="ui fluid dropdown">
<option value="1">GET</option>
<option value="2">POST</option>
</select>
</div>
<div class="field">
<label>请求类型</label>
<select name="RequestType" class="ui fluid dropdown">
<option value="1">JSON</option>
<option value="2">FORM</option>
</select>
</div>
<div class="secret field">
<label>Body</label>
<textarea name="RequestBody"></textarea>
</div>
<div class="field">
<div class="ui checkbox">
<input name="VerifySSL" type="checkbox" tabindex="0" class="hidden">
<label>验证SSL</label>
</div>
</div>
</form>
</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,29 @@
{{define "component/rule"}}
<div class="ui tiny notification modal transition hidden">
<div class="header">添加通知规则</div>
<div class="content">
<form id="notificationForm" class="ui form">
<input type="hidden" name="ID">
<div class="field">
<label>备注</label>
<input type="text" name="Name">
</div>
<div class="secret field">
<label>规则</label>
<textarea name="RequestBody"></textarea>
</div>
<div class="field">
<div class="ui checkbox">
<input name="VerifySSL" type="checkbox" tabindex="0" class="hidden">
<label>启用</label>
</div>
</div>
</form>
</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

@ -16,7 +16,7 @@
</div> </div>
<div class=" actions"> <div class=" actions">
<div class="ui negative button">取消</div> <div class="ui negative button">取消</div>
<button class="ui positive right labeled icon button">绑定<i class="checkmark icon"></i> <button class="ui positive right labeled icon button">确认<i class="checkmark icon"></i>
</button> </button>
</div> </div>
</div> </div>

View File

@ -0,0 +1,88 @@
{{define "dashboard/notification"}}
{{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="addOrEditNotification()"><i
class="add icon"></i> 添加通知方式
</button>
</div>
</div>
<table class="ui very basic table">
<thead>
<tr>
<th>ID</th>
<th>备注</th>
<th>URL</th>
<th>管理</th>
</tr>
</thead>
<tbody>
{{range $notification := .Notifications}}
<tr>
<td>{{$notification.ID}}</td>
<td>{{$notification.Name}}</td>
<td>{{$notification.URL}}</td>
<td>
<div class="ui mini icon buttons">
<button class="ui button" onclick="addOrEditNotification({{$notification}})">
<i class="edit icon"></i>
</button>
<button class="ui button"
onclick="showConfirm('删除通知方式','确认删除此通知方式?',deleteRequest,'/api/notification/'+{{$notification.ID}})">
<i class="delete icon"></i>
</button>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
<div class="ui grid">
<div class="right floated right aligned twelve wide column">
<button class="ui right labeled positive icon button" onclick="addOrEditNotification()"><i
class="add icon"></i> 添加报警规则
</button>
</div>
</div>
<table class="ui very basic table">
<thead>
<tr>
<th>ID</th>
<th>备注</th>
<th>URL</th>
<th>管理</th>
</tr>
</thead>
<tbody>
{{range $notification := .Notifications}}
<tr>
<td>{{$notification.ID}}</td>
<td>{{$notification.Name}}</td>
<td>{{$notification.URL}}</td>
<td>
<div class="ui mini icon buttons">
<button class="ui button" onclick="addOrEditNotification({{$notification}})">
<i class="edit icon"></i>
</button>
<button class="ui button"
onclick="showConfirm('删除通知方式','确认删除此通知方式?',deleteRequest,'/api/notification/'+{{$notification.ID}})">
<i class="delete icon"></i>
</button>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{template "component/notification"}}
{{template "common/footer" .}}
<script>
$('.checkbox').checkbox()
</script>
{{end}}

View File

@ -20,7 +20,7 @@
</div> </div>
<div class="field"> <div class="field">
<label>自定义CSS</label> <label>自定义CSS</label>
<textarea name="CustomCSS" value="{{.Conf.Site.CustomCSS}}"></textarea> <textarea name="CustomCSS">{{.Conf.Site.CustomCSS}}</textarea>
</div> </div>
<button class="ui button" type="submit">保存</button> <button class="ui button" type="submit">保存</button>
</form> </form>

View File

@ -134,8 +134,6 @@
</div> </div>
</div> </div>
</div> </div>
<button id="darkmodeButton"></button>
<footer> <footer>
<p style="text-align:center;padding: 15px;">Powered by <a href="https://github.com/naiba/nezha">哪吒面板</a> build · <p style="text-align:center;padding: 15px;">Powered by <a href="https://github.com/naiba/nezha">哪吒面板</a> build ·
{{.Version}} {{.Version}}
@ -202,30 +200,6 @@
if (hour > 17 || hour < 4) { if (hour > 17 || hour < 4) {
document.body.classList.add('dark'); document.body.classList.add('dark');
} }
return
//darkmode
if (document.getElementById("darkmodeButton")) {
var night = parseInt(document.cookie.replace(/(?:(?:^|.*;\s*)dark\s*=\s*([^;]*).*$)|^.*$/, "$1") || '0');
if (night) {
document.body.classList.add('dark');
console.log('Dark mode on', night);
}
document.getElementById("darkmodeButton").onclick = function () {
night = parseInt(document.cookie.replace(/(?:(?:^|.*;\s*)dark\s*=\s*([^;]*).*$)|^.*$/, "$1") || '0');
if (!night) {
document.body.classList.add('dark');
document.cookie = "dark=1";
console.log('Dark mode on', night);
} else {
document.body.classList.remove('dark');
document.cookie = "dark=0";
console.log('Dark mode off', night);
}
}
} else {
document.cookie && (document.cookie = "");
console.log('Darkmode not Support');
}
}, },
secondToDate(s) { secondToDate(s) {
var d = Math.floor(s / 3600 / 24); var d = Math.floor(s / 3600 / 24);