From ecc51001c35a34b90b39247cd10780d3621d1aa9 Mon Sep 17 00:00:00 2001 From: ulricqin Date: Fri, 20 May 2022 23:48:49 +0800 Subject: [PATCH] New Dashboard and support variables in alert_rule_note (#953) * change alert rule * Db connect update (#939) * update target's cluster field when clustername modified in server.conf * code refactor * db connect update * delete DriverName Co-authored-by: Ulric Qin Co-authored-by: zhangjiandong * update sql struct * change sql * add some files for new dashboard * add new board apis * fix query data * add dashboard migrate api * rule note support template * add value as data for template * parse rule note before persist * use prometheus var names * fixbug rule note template * refactor sql * add logo * refactor: add some log * mv package poster to pkg * add version * compute user total in usage reporter * feat: add some service api Co-authored-by: 710leo <710leo@gmail.com> Co-authored-by: countingwww <871138993@qq.com> Co-authored-by: zhangjiandong --- Makefile | 4 +- README.md | 2 + README_ZH.md | 2 + doc/img/ccf-n9e.png | Bin 0 -> 26867 bytes docker/initsql/a-n9e.sql | 38 +++- etc/server.conf | 30 +--- etc/webapi.conf | 31 +--- src/main.go | 10 +- src/models/alert_cur_event.go | 36 ++++ src/models/alert_his_event.go | 2 + src/models/alert_rule.go | 40 ++++- src/models/board.go | 125 ++++++++++++++ src/models/board_payload.go | 58 +++++++ src/models/dashboard.go | 6 + src/pkg/ormx/ormx.go | 9 +- src/{server/common => pkg}/poster/post.go | 0 src/pkg/tplx/tplx.go | 25 +++ src/pkg/version/version.go | 4 + src/server/common/conv/conv.go | 4 + src/server/common/sender/dingtalk.go | 2 +- src/server/common/sender/feishu.go | 2 +- src/server/common/sender/wecom.go | 2 +- src/server/config/config.go | 6 +- src/server/engine/callback.go | 2 +- src/server/engine/consume.go | 8 +- src/server/engine/logger.go | 2 +- src/server/engine/notify.go | 26 +-- src/server/engine/worker.go | 52 +++++- src/server/router/router.go | 3 + src/server/router/router_event.go | 31 ++++ src/server/server.go | 6 +- src/server/usage/usage.go | 11 ++ src/storage/storage.go | 77 +-------- src/webapi/config/config.go | 5 +- src/webapi/router/router.go | 44 +++-- src/webapi/router/router_alert_rule.go | 72 +++++++- src/webapi/router/router_board.go | 200 ++++++++++++++++++++++ src/webapi/router/router_builtin.go | 27 ++- src/webapi/router/router_mw.go | 14 ++ src/webapi/router/router_target.go | 88 ++++++++-- src/webapi/webapi.go | 6 +- 41 files changed, 872 insertions(+), 240 deletions(-) create mode 100644 doc/img/ccf-n9e.png create mode 100644 src/models/board.go create mode 100644 src/models/board_payload.go rename src/{server/common => pkg}/poster/post.go (100%) create mode 100644 src/pkg/tplx/tplx.go create mode 100644 src/pkg/version/version.go create mode 100644 src/server/router/router_event.go create mode 100644 src/webapi/router/router_board.go diff --git a/Makefile b/Makefile index f726f6c9..22a6dca4 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ NOW = $(shell date -u '+%Y%m%d%I%M%S') -RELEASE_VERSION = 5.7.1 +RELEASE_VERSION = 5.8.0 APP = n9e SERVER_BIN = $(APP) @@ -15,7 +15,7 @@ SERVER_BIN = $(APP) all: build build: - go build -ldflags "-w -s -X main.VERSION=$(RELEASE_VERSION)" -o $(SERVER_BIN) ./src + go build -ldflags "-w -s -X github.com/didi/nightingale/v5/src/pkg/version.VERSION=$(RELEASE_VERSION)" -o $(SERVER_BIN) ./src # start: # @go run -ldflags "-X main.VERSION=$(RELEASE_TAG)" ./cmd/${APP}/main.go web -c ./configs/config.toml -m ./configs/model.conf --menu ./configs/menu.yaml diff --git a/README.md b/README.md index 606729e2..ee65177a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ + + # Nightingale Nightingale is an enterprise-level cloud-native monitoring system, which can be used as drop-in replacement of Prometheus for alerting and management. diff --git a/README_ZH.md b/README_ZH.md index d7138e42..d0d0f9a4 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -1,3 +1,5 @@ + + # Nightingale [English](./README.md) | [中文](./README_ZH.md) diff --git a/doc/img/ccf-n9e.png b/doc/img/ccf-n9e.png new file mode 100644 index 0000000000000000000000000000000000000000..6903103d615e8127ff4929c248098e531d334272 GIT binary patch literal 26867 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 O-f~jPk`>}c0sjYc*UPd1 literal 0 HcmV?d00001 diff --git a/docker/initsql/a-n9e.sql b/docker/initsql/a-n9e.sql index d544d09e..543af628 100644 --- a/docker/initsql/a-n9e.sql +++ b/docker/initsql/a-n9e.sql @@ -152,6 +152,28 @@ CREATE TABLE `busi_group_member` ( insert into busi_group_member(busi_group_id, user_group_id, perm_flag) values(1, 1, "rw"); +-- for dashboard new version +CREATE TABLE `board` ( + `id` bigint unsigned not null auto_increment, + `group_id` bigint not null default 0 comment 'busi group id', + `name` varchar(191) not null, + `tags` varchar(255) not null comment 'split by space', + `create_at` bigint not null default 0, + `create_by` varchar(64) not null default '', + `update_at` bigint not null default 0, + `update_by` varchar(64) not null default '', + PRIMARY KEY (`id`), + UNIQUE KEY (`group_id`, `name`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; + +-- for dashboard new version +CREATE TABLE `board_payload` ( + `id` bigint unsigned not null comment 'dashboard id', + `payload` mediumtext not null, + UNIQUE KEY (`id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; + +-- deprecated CREATE TABLE `dashboard` ( `id` bigint unsigned not null auto_increment, `group_id` bigint not null default 0 comment 'busi group id', @@ -166,6 +188,7 @@ CREATE TABLE `dashboard` ( UNIQUE KEY (`group_id`, `name`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; +-- deprecated -- auto create the first subclass 'Default chart group' of dashboard CREATE TABLE `chart_group` ( `id` bigint unsigned not null auto_increment, @@ -176,6 +199,7 @@ CREATE TABLE `chart_group` ( KEY (`dashboard_id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; +-- deprecated CREATE TABLE `chart` ( `id` bigint unsigned not null auto_increment, `group_id` bigint unsigned not null comment 'chart group id', @@ -200,7 +224,11 @@ CREATE TABLE `alert_rule` ( `group_id` bigint not null default 0 comment 'busi group id', `cluster` varchar(128) not null, `name` varchar(255) not null, - `note` varchar(255) not null, + `note` varchar(1024) not null default '', + `prod` varchar(255) not null default '', + `algorithm` varchar(255) not null default '', + `algo_params` varchar(255), + `delay` int not null default 0, `severity` tinyint(1) not null comment '0:Emergency 1:Warning 2:Notice', `disabled` tinyint(1) not null comment '0:enabled 1:disabled', `prom_for_duration` int not null comment 'prometheus for, unit:s', @@ -333,7 +361,9 @@ CREATE TABLE `alert_cur_event` ( `hash` varchar(64) not null comment 'rule_id + vector_pk', `rule_id` bigint unsigned not null, `rule_name` varchar(255) not null, - `rule_note` varchar(512) not null default 'alert rule note', + `rule_note` varchar(2048) not null default 'alert rule note', + `rule_prod` varchar(255) not null default '', + `rule_algo` varchar(255) not null default '', `severity` tinyint(1) not null comment '0:Emergency 1:Warning 2:Notice', `prom_for_duration` int not null comment 'prometheus for, unit:s', `prom_ql` varchar(8192) not null comment 'promql', @@ -365,7 +395,9 @@ CREATE TABLE `alert_his_event` ( `hash` varchar(64) not null comment 'rule_id + vector_pk', `rule_id` bigint unsigned not null, `rule_name` varchar(255) not null, - `rule_note` varchar(512) not null default 'alert rule note', + `rule_note` varchar(2048) not null default 'alert rule note', + `rule_prod` varchar(255) not null default '', + `rule_algo` varchar(255) not null default '', `severity` tinyint(1) not null comment '0:Emergency 1:Warning 2:Notice', `prom_for_duration` int not null comment 'prometheus for, unit:s', `prom_ql` varchar(8192) not null comment 'promql', diff --git a/etc/server.conf b/etc/server.conf index fdf8ff24..7a3bc5eb 100644 --- a/etc/server.conf +++ b/etc/server.conf @@ -123,7 +123,9 @@ Address = "127.0.0.1:6379" # UseTLS = false # TLSMinVersion = "1.2" -[Gorm] +[DB] +# postgres: host=%s port=%s user=%s dbname=%s password=%s sslmode=%s +DSN="root:1234@tcp(127.0.0.1:3306)/n9e_v5?charset=utf8mb4&parseTime=True&loc=Local&allowNativePasswords=true" # enable debug mode or not Debug = false # mysql postgres @@ -137,31 +139,7 @@ MaxIdleConns = 50 # table prefix TablePrefix = "" # enable auto migrate or not -EnableAutoMigrate = false - -[MySQL] -# mysql address host:port -Address = "127.0.0.1:3306" -# mysql username -User = "root" -# mysql password -Password = "1234" -# database name -DBName = "n9e_v5" -# connection params -Parameters = "charset=utf8mb4&parseTime=True&loc=Local&allowNativePasswords=true" - -[Postgres] -# pg address host:port -Address = "127.0.0.1:5432" -# pg user -User = "root" -# pg password -Password = "1234" -# database name -DBName = "n9e_v5" -# ssl mode -SSLMode = "disable" +# EnableAutoMigrate = false [Reader] # prometheus base url diff --git a/etc/webapi.conf b/etc/webapi.conf index ddcd15b7..7cbd2642 100644 --- a/etc/webapi.conf +++ b/etc/webapi.conf @@ -144,9 +144,10 @@ Address = "127.0.0.1:6379" # UseTLS = false # TLSMinVersion = "1.2" -[Gorm] +[DB] +DSN="root:1234@tcp(127.0.0.1:3306)/n9e_v5?charset=utf8mb4&parseTime=True&loc=Local&allowNativePasswords=true" # enable debug mode or not -Debug = true +Debug = false # mysql postgres DBType = "mysql" # unit: s @@ -158,31 +159,7 @@ MaxIdleConns = 50 # table prefix TablePrefix = "" # enable auto migrate or not -EnableAutoMigrate = false - -[MySQL] -# mysql address host:port -Address = "127.0.0.1:3306" -# mysql username -User = "root" -# mysql password -Password = "1234" -# database name -DBName = "n9e_v5" -# connection params -Parameters = "charset=utf8mb4&parseTime=True&loc=Local&allowNativePasswords=true" - -[Postgres] -# pg address host:port -Address = "127.0.0.1:5432" -# pg user -User = "root" -# pg password -Password = "1234" -# database name -DBName = "n9e_v5" -# ssl mode -SSLMode = "disable" +# EnableAutoMigrate = false [[Clusters]] # Prometheus cluster name diff --git a/src/main.go b/src/main.go index 88500c4d..bdeb986c 100644 --- a/src/main.go +++ b/src/main.go @@ -7,17 +7,15 @@ import ( "github.com/toolkits/pkg/runner" "github.com/urfave/cli/v2" + "github.com/didi/nightingale/v5/src/pkg/version" "github.com/didi/nightingale/v5/src/server" "github.com/didi/nightingale/v5/src/webapi" ) -// VERSION go build -ldflags "-X main.VERSION=x.x.x" -var VERSION = "not specified" - func main() { app := cli.NewApp() app.Name = "n9e" - app.Version = VERSION + app.Version = version.VERSION app.Usage = "Nightingale, enterprise prometheus management" app.Commands = []*cli.Command{ newWebapiCmd(), @@ -44,7 +42,7 @@ func newWebapiCmd() *cli.Command { if c.String("conf") != "" { opts = append(opts, webapi.SetConfigFile(c.String("conf"))) } - opts = append(opts, webapi.SetVersion(VERSION)) + opts = append(opts, webapi.SetVersion(version.VERSION)) webapi.Run(opts...) return nil @@ -70,7 +68,7 @@ func newServerCmd() *cli.Command { if c.String("conf") != "" { opts = append(opts, server.SetConfigFile(c.String("conf"))) } - opts = append(opts, server.SetVersion(VERSION)) + opts = append(opts, server.SetVersion(version.VERSION)) server.Run(opts...) return nil diff --git a/src/models/alert_cur_event.go b/src/models/alert_cur_event.go index 580c9d4a..7fa101ca 100644 --- a/src/models/alert_cur_event.go +++ b/src/models/alert_cur_event.go @@ -1,9 +1,13 @@ package models import ( + "bytes" "fmt" + "html/template" "strconv" "strings" + + "github.com/didi/nightingale/v5/src/pkg/tplx" ) type AlertCurEvent struct { @@ -15,6 +19,8 @@ type AlertCurEvent struct { RuleId int64 `json:"rule_id"` RuleName string `json:"rule_name"` RuleNote string `json:"rule_note"` + RuleProd string `json:"rule_prod"` + RuleAlgo string `json:"rule_algo"` Severity int `json:"severity"` PromForDuration int `json:"prom_for_duration"` PromQl string `json:"prom_ql"` @@ -54,6 +60,34 @@ type AggrRule struct { Value string } +func (e *AlertCurEvent) ParseRuleNote() error { + e.RuleNote = strings.TrimSpace(e.RuleNote) + + if e.RuleNote == "" { + return nil + } + + var defs = []string{ + "{{$labels := .TagsMap}}", + "{{$value := .TriggerValue}}", + } + + text := strings.Join(append(defs, e.RuleNote), "") + t, err := template.New(fmt.Sprint(e.RuleId)).Funcs(tplx.TemplateFuncMap).Parse(text) + if err != nil { + return err + } + + var body bytes.Buffer + err = t.Execute(&body, e) + if err != nil { + return err + } + + e.RuleNote = body.String() + return nil +} + func (e *AlertCurEvent) GenCardTitle(rules []*AggrRule) string { arr := make([]string, len(rules)) for i := 0; i < len(rules); i++ { @@ -125,6 +159,8 @@ func (e *AlertCurEvent) ToHis() *AlertHisEvent { Hash: e.Hash, RuleId: e.RuleId, RuleName: e.RuleName, + RuleProd: e.RuleProd, + RuleAlgo: e.RuleAlgo, RuleNote: e.RuleNote, Severity: e.Severity, PromForDuration: e.PromForDuration, diff --git a/src/models/alert_his_event.go b/src/models/alert_his_event.go index d0222028..7b01404d 100644 --- a/src/models/alert_his_event.go +++ b/src/models/alert_his_event.go @@ -15,6 +15,8 @@ type AlertHisEvent struct { RuleId int64 `json:"rule_id"` RuleName string `json:"rule_name"` RuleNote string `json:"rule_note"` + RuleProd string `json:"rule_prod"` + RuleAlgo string `json:"rule_algo"` Severity int `json:"severity"` PromForDuration int `json:"prom_for_duration"` PromQl string `json:"prom_ql"` diff --git a/src/models/alert_rule.go b/src/models/alert_rule.go index c78e05ca..6a186a59 100644 --- a/src/models/alert_rule.go +++ b/src/models/alert_rule.go @@ -1,6 +1,7 @@ package models import ( + "encoding/json" "fmt" "strconv" "strings" @@ -18,6 +19,11 @@ type AlertRule struct { Cluster string `json:"cluster"` // take effect by cluster Name string `json:"name"` // rule name Note string `json:"note"` // will sent in notify + Prod string `json:"prod"` // product empty means n9e + Algorithm string `json:"algorithm"` // algorithm (''|holtwinters), empty means threshold + AlgoParams string `json:"-" gorm:"algo_params"` // params algorithm need + AlgoParamsJson interface{} `json:"algo_params" gorm:"-"` // + Delay int `json:"delay"` // Time (in seconds) to delay evaluation Severity int `json:"severity"` // 0: Emergency 1: Warning 2: Notice Disabled int `json:"disabled"` // 0: enabled, 1: disabled PromForDuration int `json:"prom_for_duration"` // prometheus for, unit:s @@ -145,7 +151,11 @@ func (ar *AlertRule) Update(arf AlertRule) error { } } - arf.FE2DB() + err := arf.FE2DB() + if err != nil { + return err + } + arf.Id = ar.Id arf.GroupId = ar.GroupId arf.CreateAt = ar.CreateAt @@ -203,12 +213,19 @@ func (ar *AlertRule) FillNotifyGroups(cache map[int64]*UserGroup) error { return nil } -func (ar *AlertRule) FE2DB() { +func (ar *AlertRule) FE2DB() error { ar.EnableDaysOfWeek = strings.Join(ar.EnableDaysOfWeekJSON, " ") ar.NotifyChannels = strings.Join(ar.NotifyChannelsJSON, " ") ar.NotifyGroups = strings.Join(ar.NotifyGroupsJSON, " ") ar.Callbacks = strings.Join(ar.CallbacksJSON, " ") ar.AppendTags = strings.Join(ar.AppendTagsJSON, " ") + algoParamsByte, err := json.Marshal(ar.AlgoParamsJson) + if err != nil { + return fmt.Errorf("marshal algo_params err:%v", err) + } + + ar.AlgoParams = string(algoParamsByte) + return nil } func (ar *AlertRule) DB2FE() { @@ -217,6 +234,7 @@ func (ar *AlertRule) DB2FE() { ar.NotifyGroupsJSON = strings.Fields(ar.NotifyGroups) ar.CallbacksJSON = strings.Fields(ar.Callbacks) ar.AppendTagsJSON = strings.Fields(ar.AppendTags) + json.Unmarshal([]byte(ar.AlgoParams), &ar.AlgoParamsJson) } func AlertRuleDels(ids []int64, busiGroupId int64) error { @@ -254,7 +272,7 @@ func AlertRuleGets(groupId int64) ([]AlertRule, error) { } func AlertRuleGetsByCluster(cluster string) ([]*AlertRule, error) { - session := DB().Where("disabled = ?", 0) + session := DB().Where("disabled = ? and prod = ?", 0, "") if cluster != "" { session = session.Where("cluster = ?", cluster) @@ -271,6 +289,20 @@ func AlertRuleGetsByCluster(cluster string) ([]*AlertRule, error) { return lst, err } +func AlertRulesGetByProds(prods []string) ([]*AlertRule, error) { + session := DB().Where("disabled = ? and prod IN (?)", 0, prods) + + var lst []*AlertRule + err := session.Find(&lst).Error + if err == nil { + for i := 0; i < len(lst); i++ { + lst[i].DB2FE() + } + } + + return lst, err +} + func AlertRuleGet(where string, args ...interface{}) (*AlertRule, error) { var lst []*AlertRule err := DB().Where(where, args...).Find(&lst).Error @@ -306,7 +338,7 @@ func AlertRuleGetName(id int64) (string, error) { } func AlertRuleStatistics(cluster string) (*Statistics, error) { - session := DB().Model(&AlertRule{}).Select("count(*) as total", "max(update_at) as last_updated").Where("disabled = ?", 0) + session := DB().Model(&AlertRule{}).Select("count(*) as total", "max(update_at) as last_updated").Where("disabled = ? and prod = ?", 0, "") if cluster != "" { session = session.Where("cluster = ?", cluster) diff --git a/src/models/board.go b/src/models/board.go new file mode 100644 index 00000000..397e844b --- /dev/null +++ b/src/models/board.go @@ -0,0 +1,125 @@ +package models + +import ( + "strings" + "time" + + "github.com/pkg/errors" + "github.com/toolkits/pkg/str" + "gorm.io/gorm" +) + +type Board struct { + Id int64 `json:"id" gorm:"primaryKey"` + GroupId int64 `json:"group_id"` + Name string `json:"name"` + Tags string `json:"tags"` + CreateAt int64 `json:"create_at"` + CreateBy string `json:"create_by"` + UpdateAt int64 `json:"update_at"` + UpdateBy string `json:"update_by"` + Configs string `json:"configs" gorm:"-"` +} + +func (b *Board) TableName() string { + return "board" +} + +func (b *Board) Verify() error { + if b.Name == "" { + return errors.New("Name is blank") + } + + if str.Dangerous(b.Name) { + return errors.New("Name has invalid characters") + } + + return nil +} + +func (b *Board) Add() error { + if err := b.Verify(); err != nil { + return err + } + + now := time.Now().Unix() + b.CreateAt = now + b.UpdateAt = now + + return Insert(b) +} + +func (b *Board) Update(selectField interface{}, selectFields ...interface{}) error { + if err := b.Verify(); err != nil { + return err + } + + return DB().Model(b).Select(selectField, selectFields...).Updates(b).Error +} + +func (b *Board) Del() error { + return DB().Transaction(func(tx *gorm.DB) error { + if err := tx.Where("id=?", b.Id).Delete(&BoardPayload{}).Error; err != nil { + return err + } + + if err := tx.Where("id=?", b.Id).Delete(&Board{}).Error; err != nil { + return err + } + + return nil + }) +} + +// BoardGet for detail page +func BoardGet(where string, args ...interface{}) (*Board, error) { + var lst []*Board + err := DB().Where(where, args...).Find(&lst).Error + if err != nil { + return nil, err + } + + if len(lst) == 0 { + return nil, nil + } + + payload, err := BoardPayloadGet(lst[0].Id) + if err != nil { + return nil, err + } + + lst[0].Configs = payload + + return lst[0], nil +} + +func BoardCount(where string, args ...interface{}) (num int64, err error) { + return Count(DB().Model(&Board{}).Where(where, args...)) +} + +func BoardExists(where string, args ...interface{}) (bool, error) { + num, err := BoardCount(where, args...) + return num > 0, err +} + +// BoardGets for list page +func BoardGets(groupId int64, query string) ([]Board, error) { + session := DB().Where("group_id=?", groupId).Order("name") + + arr := strings.Fields(query) + if len(arr) > 0 { + for i := 0; i < len(arr); i++ { + if strings.HasPrefix(arr[i], "-") { + q := "%" + arr[i][1:] + "%" + session = session.Where("name not like ? and tags not like ?", q, q) + } else { + q := "%" + arr[i] + "%" + session = session.Where("(name like ? or tags like ?)", q, q) + } + } + } + + var objs []Board + err := session.Find(&objs).Error + return objs, err +} diff --git a/src/models/board_payload.go b/src/models/board_payload.go new file mode 100644 index 00000000..a988227c --- /dev/null +++ b/src/models/board_payload.go @@ -0,0 +1,58 @@ +package models + +import "errors" + +type BoardPayload struct { + Id int64 `json:"id" gorm:"primaryKey"` + Payload string `json:"payload"` +} + +func (p *BoardPayload) TableName() string { + return "board_payload" +} + +func (p *BoardPayload) Update(selectField interface{}, selectFields ...interface{}) error { + return DB().Model(p).Select(selectField, selectFields...).Updates(p).Error +} + +func BoardPayloadGets(ids []int64) ([]*BoardPayload, error) { + if len(ids) == 0 { + return nil, errors.New("empty ids") + } + + var arr []*BoardPayload + err := DB().Where("id in ?", ids).Find(&arr).Error + return arr, err +} + +func BoardPayloadGet(id int64) (string, error) { + payloads, err := BoardPayloadGets([]int64{id}) + if err != nil { + return "", err + } + + if len(payloads) == 0 { + return "", nil + } + + return payloads[0].Payload, nil +} + +func BoardPayloadSave(id int64, payload string) error { + var bp BoardPayload + err := DB().Where("id = ?", id).Find(&bp).Error + if err != nil { + return err + } + + if bp.Id > 0 { + // already exists + bp.Payload = payload + return bp.Update("payload") + } + + return Insert(&BoardPayload{ + Id: id, + Payload: payload, + }) +} diff --git a/src/models/dashboard.go b/src/models/dashboard.go index 45f47874..6d083b9b 100644 --- a/src/models/dashboard.go +++ b/src/models/dashboard.go @@ -160,3 +160,9 @@ func DashboardGetsByIds(ids []int64) ([]Dashboard, error) { err := DB().Where("id in ?", ids).Order("name").Find(&lst).Error return lst, err } + +func DashboardGetAll() ([]Dashboard, error) { + var lst []Dashboard + err := DB().Find(&lst).Error + return lst, err +} diff --git a/src/pkg/ormx/ormx.go b/src/pkg/ormx/ormx.go index 6eb403a5..814c00e7 100644 --- a/src/pkg/ormx/ormx.go +++ b/src/pkg/ormx/ormx.go @@ -1,18 +1,17 @@ package ormx import ( + "time" "fmt" "strings" - "time" - "gorm.io/driver/mysql" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/schema" ) -// Config GORM Config -type Config struct { +// DBConfig GORM DBConfig +type DBConfig struct { Debug bool DBType string DSN string @@ -23,7 +22,7 @@ type Config struct { } // New Create gorm.DB instance -func New(c Config) (*gorm.DB, error) { +func New(c DBConfig) (*gorm.DB, error) { var dialector gorm.Dialector switch strings.ToLower(c.DBType) { diff --git a/src/server/common/poster/post.go b/src/pkg/poster/post.go similarity index 100% rename from src/server/common/poster/post.go rename to src/pkg/poster/post.go diff --git a/src/pkg/tplx/tplx.go b/src/pkg/tplx/tplx.go new file mode 100644 index 00000000..5f6a225c --- /dev/null +++ b/src/pkg/tplx/tplx.go @@ -0,0 +1,25 @@ +package tplx + +import ( + "html/template" + "time" +) + +var TemplateFuncMap = template.FuncMap{ + "unescaped": func(str string) interface{} { return template.HTML(str) }, + "urlconvert": func(str string) interface{} { return template.URL(str) }, + "timeformat": func(ts int64, pattern ...string) string { + defp := "2006-01-02 15:04:05" + if len(pattern) > 0 { + defp = pattern[0] + } + return time.Unix(ts, 0).Format(defp) + }, + "timestamp": func(pattern ...string) string { + defp := "2006-01-02 15:04:05" + if len(pattern) > 0 { + defp = pattern[0] + } + return time.Now().Format(defp) + }, +} diff --git a/src/pkg/version/version.go b/src/pkg/version/version.go new file mode 100644 index 00000000..d0dc2a1f --- /dev/null +++ b/src/pkg/version/version.go @@ -0,0 +1,4 @@ +package version + +// VERSION go build -ldflags "-X pkg.version.VERSION=x.x.x" +var VERSION = "not specified" diff --git a/src/server/common/conv/conv.go b/src/server/common/conv/conv.go index 561eb51f..717cbccc 100644 --- a/src/server/common/conv/conv.go +++ b/src/server/common/conv/conv.go @@ -14,6 +14,10 @@ type Vector struct { } func ConvertVectors(value model.Value) (lst []Vector) { + if value == nil { + return + } + switch value.Type() { case model.ValVector: items, ok := value.(model.Vector) diff --git a/src/server/common/sender/dingtalk.go b/src/server/common/sender/dingtalk.go index e845e2d5..178e62ce 100644 --- a/src/server/common/sender/dingtalk.go +++ b/src/server/common/sender/dingtalk.go @@ -5,7 +5,7 @@ import ( "strings" "time" - "github.com/didi/nightingale/v5/src/server/common/poster" + "github.com/didi/nightingale/v5/src/pkg/poster" "github.com/toolkits/pkg/logger" ) diff --git a/src/server/common/sender/feishu.go b/src/server/common/sender/feishu.go index e78d9eaf..829a06b4 100644 --- a/src/server/common/sender/feishu.go +++ b/src/server/common/sender/feishu.go @@ -3,7 +3,7 @@ package sender import ( "time" - "github.com/didi/nightingale/v5/src/server/common/poster" + "github.com/didi/nightingale/v5/src/pkg/poster" "github.com/toolkits/pkg/logger" ) diff --git a/src/server/common/sender/wecom.go b/src/server/common/sender/wecom.go index 5671fd9a..1634247e 100644 --- a/src/server/common/sender/wecom.go +++ b/src/server/common/sender/wecom.go @@ -3,7 +3,7 @@ package sender import ( "time" - "github.com/didi/nightingale/v5/src/server/common/poster" + "github.com/didi/nightingale/v5/src/pkg/poster" "github.com/toolkits/pkg/logger" ) diff --git a/src/server/config/config.go b/src/server/config/config.go index ae6fe16c..19c84075 100644 --- a/src/server/config/config.go +++ b/src/server/config/config.go @@ -13,6 +13,7 @@ import ( "github.com/didi/nightingale/v5/src/pkg/httpx" "github.com/didi/nightingale/v5/src/pkg/logx" + "github.com/didi/nightingale/v5/src/pkg/ormx" "github.com/didi/nightingale/v5/src/server/reader" "github.com/didi/nightingale/v5/src/server/writer" "github.com/didi/nightingale/v5/src/storage" @@ -122,6 +123,7 @@ type Config struct { RunMode string ClusterName string BusiGroupLabelKey string + AnomalyDataApi []string EngineDelay int64 DisableUsageReport bool Log logx.Config @@ -132,9 +134,7 @@ type Config struct { Alerting Alerting NoData NoData Redis storage.RedisConfig - Gorm storage.Gorm - MySQL storage.MySQL - Postgres storage.Postgres + DB ormx.DBConfig WriterOpt writer.GlobalOpt Writers []writer.Options Reader reader.Options diff --git a/src/server/engine/callback.go b/src/server/engine/callback.go index 2c21a3d6..b5578beb 100644 --- a/src/server/engine/callback.go +++ b/src/server/engine/callback.go @@ -9,7 +9,7 @@ import ( "github.com/didi/nightingale/v5/src/models" "github.com/didi/nightingale/v5/src/pkg/ibex" - "github.com/didi/nightingale/v5/src/server/common/poster" + "github.com/didi/nightingale/v5/src/pkg/poster" "github.com/didi/nightingale/v5/src/server/config" "github.com/didi/nightingale/v5/src/server/memsto" ) diff --git a/src/server/engine/consume.go b/src/server/engine/consume.go index c34bb421..273c8d84 100644 --- a/src/server/engine/consume.go +++ b/src/server/engine/consume.go @@ -2,6 +2,7 @@ package engine import ( "context" + "fmt" "strconv" "time" @@ -42,7 +43,12 @@ func consume(events []interface{}, sema *semaphore.Semaphore) { } func consumeOne(event *models.AlertCurEvent) { - logEvent(event, "consume") + LogEvent(event, "consume") + + if err := event.ParseRuleNote(); err != nil { + event.RuleNote = fmt.Sprintf("failed to parse rule note: %v", err) + } + persist(event) if event.IsRecovered && event.NotifyRecovered == 0 { diff --git a/src/server/engine/logger.go b/src/server/engine/logger.go index 9ee48936..f72d6eb6 100644 --- a/src/server/engine/logger.go +++ b/src/server/engine/logger.go @@ -5,7 +5,7 @@ import ( "github.com/toolkits/pkg/logger" ) -func logEvent(event *models.AlertCurEvent, location string, err ...error) { +func LogEvent(event *models.AlertCurEvent, location string, err ...error) { status := "triggered" if event.IsRecovered { status = "recovered" diff --git a/src/server/engine/notify.go b/src/server/engine/notify.go index 8a4a2935..359504e3 100644 --- a/src/server/engine/notify.go +++ b/src/server/engine/notify.go @@ -23,6 +23,7 @@ import ( "github.com/didi/nightingale/v5/src/models" "github.com/didi/nightingale/v5/src/pkg/sys" + "github.com/didi/nightingale/v5/src/pkg/tplx" "github.com/didi/nightingale/v5/src/server/common/sender" "github.com/didi/nightingale/v5/src/server/config" "github.com/didi/nightingale/v5/src/server/memsto" @@ -31,25 +32,6 @@ import ( var tpls = make(map[string]*template.Template) -var fns = template.FuncMap{ - "unescaped": func(str string) interface{} { return template.HTML(str) }, - "urlconvert": func(str string) interface{} { return template.URL(str) }, - "timeformat": func(ts int64, pattern ...string) string { - defp := "2006-01-02 15:04:05" - if len(pattern) > 0 { - defp = pattern[0] - } - return time.Unix(ts, 0).Format(defp) - }, - "timestamp": func(pattern ...string) string { - defp := "2006-01-02 15:04:05" - if len(pattern) > 0 { - defp = pattern[0] - } - return time.Now().Format(defp) - }, -} - func initTpls() error { if config.C.Alerting.TemplatesDir == "" { config.C.Alerting.TemplatesDir = path.Join(runner.Cwd, "etc", "template") @@ -78,7 +60,7 @@ func initTpls() error { for i := 0; i < len(tplFiles); i++ { tplpath := path.Join(config.C.Alerting.TemplatesDir, tplFiles[i]) - tpl, err := template.New(tplFiles[i]).Funcs(fns).ParseFiles(tplpath) + tpl, err := template.New(tplFiles[i]).Funcs(tplx.TemplateFuncMap).ParseFiles(tplpath) if err != nil { return errors.WithMessage(err, "failed to parse tpl: "+tplpath) } @@ -249,7 +231,7 @@ func handleNotice(notice Notice, bs []byte) { } func notify(event *models.AlertCurEvent) { - logEvent(event, "notify") + LogEvent(event, "notify") notice := genNotice(event) stdinBytes, err := json.Marshal(notice) @@ -355,7 +337,7 @@ func handleSubscribe(event models.AlertCurEvent, sub *models.AlertSubscribe) { return } - logEvent(&event, "subscribe") + LogEvent(&event, "subscribe") fillUsers(&event) diff --git a/src/server/engine/worker.go b/src/server/engine/worker.go index 66bfe2ef..84e2be2e 100644 --- a/src/server/engine/worker.go +++ b/src/server/engine/worker.go @@ -3,11 +3,14 @@ package engine import ( "context" "fmt" + "math/rand" "sort" "strings" "time" + "github.com/prometheus/common/model" "github.com/toolkits/pkg/logger" + "github.com/toolkits/pkg/net/httplib" "github.com/toolkits/pkg/str" "github.com/didi/nightingale/v5/src/models" @@ -89,6 +92,11 @@ func (r RuleEval) Start() { } } +type AnomalyPoint struct { + Data model.Matrix `json:"data"` + Err string `json:"error"` +} + func (r RuleEval) Work() { promql := strings.TrimSpace(r.rule.PromQl) if promql == "" { @@ -96,15 +104,37 @@ func (r RuleEval) Work() { return } - value, warnings, err := reader.Reader.Client.Query(context.Background(), promql, time.Now()) - if err != nil { - logger.Errorf("rule_eval:%d promql:%s, error:%v", r.RuleID(), promql, err) - return - } + var value model.Value + var err error + if r.rule.Algorithm == "" { + var warnings reader.Warnings + value, warnings, err = reader.Reader.Client.Query(context.Background(), promql, time.Now()) + if err != nil { + logger.Errorf("rule_eval:%d promql:%s, error:%v", r.RuleID(), promql, err) + return + } - if len(warnings) > 0 { - logger.Errorf("rule_eval:%d promql:%s, warnings:%v", r.RuleID(), promql, warnings) - return + if len(warnings) > 0 { + logger.Errorf("rule_eval:%d promql:%s, warnings:%v", r.RuleID(), promql, warnings) + return + } + } else { + var res AnomalyPoint + count := len(config.C.AnomalyDataApi) + for _, i := range rand.Perm(count) { + url := fmt.Sprintf("%s?rid=%d", config.C.AnomalyDataApi[i], r.rule.Id) + err = httplib.Get(url).SetTimeout(time.Duration(3000) * time.Millisecond).ToJSON(&res) + if err != nil { + logger.Errorf("curl %s fail: %v", url, err) + continue + } + if res.Err != "" { + logger.Errorf("curl %s fail: %s", url, res.Err) + continue + } + value = res.Data + logger.Debugf("curl %s get: %+v", url, res.Data) + } } r.judge(conv.ConvertVectors(value)) @@ -250,6 +280,8 @@ func (r RuleEval) judge(vectors []conv.Vector) { event.RuleId = r.rule.Id event.RuleName = r.rule.Name event.RuleNote = r.rule.Note + event.RuleProd = r.rule.Prod + event.RuleAlgo = r.rule.Algorithm event.Severity = r.rule.Severity event.PromForDuration = r.rule.PromForDuration event.PromQl = r.rule.PromQl @@ -364,6 +396,8 @@ func (r RuleEval) recoverRule(alertingKeys map[string]struct{}, now int64) { // 当然,其实rule的各个字段都可能发生变化了,都更新一下吧 event.RuleName = r.rule.Name event.RuleNote = r.rule.Note + event.RuleProd = r.rule.Prod + event.RuleAlgo = r.rule.Algorithm event.Severity = r.rule.Severity event.PromForDuration = r.rule.PromForDuration event.PromQl = r.rule.PromQl @@ -387,7 +421,7 @@ func (r RuleEval) pushEventToQueue(event *models.AlertCurEvent) { } promstat.CounterAlertsTotal.WithLabelValues(config.C.ClusterName).Inc() - logEvent(event, "push_queue") + LogEvent(event, "push_queue") if !EventQueue.PushFront(event) { logger.Warningf("event_push_queue: queue is full") } diff --git a/src/server/router/router.go b/src/server/router/router.go index 41937579..8c7b40c3 100644 --- a/src/server/router/router.go +++ b/src/server/router/router.go @@ -91,4 +91,7 @@ func configRoute(r *gin.Engine, version string) { r.GET("/memory/user-group", userGroupGet) r.GET("/metrics", gin.WrapH(promhttp.Handler())) + + service := r.Group("/v1/n9e") + service.POST("/event", pushEventToQueue) } diff --git a/src/server/router/router_event.go b/src/server/router/router_event.go new file mode 100644 index 00000000..e7da20ce --- /dev/null +++ b/src/server/router/router_event.go @@ -0,0 +1,31 @@ +package router + +import ( + "fmt" + + "github.com/didi/nightingale/v5/src/models" + "github.com/didi/nightingale/v5/src/server/config" + "github.com/didi/nightingale/v5/src/server/engine" + promstat "github.com/didi/nightingale/v5/src/server/stat" + + "github.com/gin-gonic/gin" + "github.com/toolkits/pkg/ginx" + "github.com/toolkits/pkg/logger" +) + +func pushEventToQueue(c *gin.Context) { + var event models.AlertCurEvent + ginx.BindJSON(c, &event) + if event.RuleId == 0 { + ginx.Bomb(200, "event is illegal") + } + + promstat.CounterAlertsTotal.WithLabelValues(config.C.ClusterName).Inc() + engine.LogEvent(&event, "http_push_queue") + if !engine.EventQueue.PushFront(event) { + msg := fmt.Sprintf("event:%+v push_queue err: queue is full", event) + ginx.Bomb(200, msg) + logger.Warningf(msg) + } + ginx.NewRender(c).Message(nil) +} diff --git a/src/server/server.go b/src/server/server.go index c5ca15ad..7c9ddf83 100644 --- a/src/server/server.go +++ b/src/server/server.go @@ -105,11 +105,7 @@ func (s Server) initialize() (func(), error) { } // init database - if err = storage.InitDB(storage.DBConfig{ - Gorm: config.C.Gorm, - MySQL: config.C.MySQL, - Postgres: config.C.Postgres, - }); err != nil { + if err = storage.InitDB(config.C.DB); err != nil { return fns.Ret(), err } diff --git a/src/server/usage/usage.go b/src/server/usage/usage.go index 2ce8ab75..f2b0055a 100644 --- a/src/server/usage/usage.go +++ b/src/server/usage/usage.go @@ -10,6 +10,8 @@ import ( "os" "time" + "github.com/didi/nightingale/v5/src/models" + "github.com/didi/nightingale/v5/src/pkg/version" "github.com/didi/nightingale/v5/src/server/common/conv" "github.com/didi/nightingale/v5/src/server/reader" ) @@ -21,8 +23,10 @@ const ( type Usage struct { Samples float64 `json:"samples"` // per second + Users float64 `json:"users"` // user total Maintainer string `json:"maintainer"` Hostname string `json:"hostname"` + Version string `json:"version"` } func getSamples() (float64, error) { @@ -61,12 +65,19 @@ func report() { return } + num, err := models.UserTotal("") + if err != nil { + return + } + maintainer := "blank" u := Usage{ Samples: sps, + Users: float64(num), Hostname: hostname, Maintainer: maintainer, + Version: version.VERSION, } post(u) diff --git a/src/storage/storage.go b/src/storage/storage.go index c9f0c960..99179cac 100644 --- a/src/storage/storage.go +++ b/src/storage/storage.go @@ -2,14 +2,10 @@ package storage import ( "context" - "errors" "fmt" "os" - "strings" - "github.com/go-redis/redis/v8" "gorm.io/gorm" - "github.com/didi/nightingale/v5/src/pkg/ormx" "github.com/didi/nightingale/v5/src/pkg/tls" ) @@ -23,85 +19,16 @@ type RedisConfig struct { tls.ClientConfig } -type DBConfig struct { - Gorm Gorm - MySQL MySQL - Postgres Postgres -} - -type Gorm struct { - Debug bool - DBType string - MaxLifetime int - MaxOpenConns int - MaxIdleConns int - TablePrefix string - EnableAutoMigrate bool -} - -type MySQL struct { - Address string - User string - Password string - DBName string - Parameters string -} - -func (a MySQL) DSN() string { - return fmt.Sprintf("%s:%s@tcp(%s)/%s?%s", - a.User, a.Password, a.Address, a.DBName, a.Parameters) -} - -type Postgres struct { - Address string - User string - Password string - DBName string - SSLMode string -} - -func (a Postgres) DSN() string { - arr := strings.Split(a.Address, ":") - if len(arr) != 2 { - panic("pg address(" + a.Address + ") invalid") - } - - return fmt.Sprintf("host=%s port=%s user=%s dbname=%s password=%s sslmode=%s", - arr[0], arr[1], a.User, a.DBName, a.Password, a.SSLMode) -} - var DB *gorm.DB -func InitDB(cfg DBConfig) error { - db, err := newGormDB(cfg) +func InitDB(cfg ormx.DBConfig) error { + db, err := ormx.New(cfg) if err == nil { DB = db } return err } -func newGormDB(cfg DBConfig) (*gorm.DB, error) { - var dsn string - switch cfg.Gorm.DBType { - case "mysql": - dsn = cfg.MySQL.DSN() - case "postgres": - dsn = cfg.Postgres.DSN() - default: - return nil, errors.New("unknown DBType") - } - - return ormx.New(ormx.Config{ - Debug: cfg.Gorm.Debug, - DBType: cfg.Gorm.DBType, - DSN: dsn, - MaxIdleConns: cfg.Gorm.MaxIdleConns, - MaxLifetime: cfg.Gorm.MaxLifetime, - MaxOpenConns: cfg.Gorm.MaxOpenConns, - TablePrefix: cfg.Gorm.TablePrefix, - }) -} - var Redis *redis.Client func InitRedis(cfg RedisConfig) (func(), error) { diff --git a/src/webapi/config/config.go b/src/webapi/config/config.go index 4696f91d..d158a154 100644 --- a/src/webapi/config/config.go +++ b/src/webapi/config/config.go @@ -13,6 +13,7 @@ import ( "github.com/didi/nightingale/v5/src/pkg/ldapx" "github.com/didi/nightingale/v5/src/pkg/logx" "github.com/didi/nightingale/v5/src/pkg/oidcc" + "github.com/didi/nightingale/v5/src/pkg/ormx" "github.com/didi/nightingale/v5/src/storage" "github.com/didi/nightingale/v5/src/webapi/prom" ) @@ -90,9 +91,7 @@ type Config struct { AnonymousAccess AnonymousAccess LDAP ldapx.LdapSection Redis storage.RedisConfig - Gorm storage.Gorm - MySQL storage.MySQL - Postgres storage.Postgres + DB ormx.DBConfig Clusters []prom.Options Ibex Ibex OIDC oidcc.Config diff --git a/src/webapi/router/router.go b/src/webapi/router/router.go index 81b0e0b4..29ff4057 100644 --- a/src/webapi/router/router.go +++ b/src/webapi/router/router.go @@ -161,12 +161,30 @@ func configRoute(r *gin.Engine, version string) { pages.GET("/targets", jwtAuth(), user(), targetGets) pages.DELETE("/targets", jwtAuth(), user(), perm("/targets/del"), targetDel) pages.GET("/targets/tags", jwtAuth(), user(), targetGetTags) - pages.POST("/targets/tags", jwtAuth(), user(), perm("/targets/put"), targetBindTags) - pages.DELETE("/targets/tags", jwtAuth(), user(), perm("/targets/put"), targetUnbindTags) + pages.POST("/targets/tags", jwtAuth(), user(), perm("/targets/put"), targetBindTagsByFE) + pages.DELETE("/targets/tags", jwtAuth(), user(), perm("/targets/put"), targetUnbindTagsByFE) pages.PUT("/targets/note", jwtAuth(), user(), perm("/targets/put"), targetUpdateNote) pages.PUT("/targets/bgid", jwtAuth(), user(), perm("/targets/put"), targetUpdateBgid) - pages.GET("/dashboards/builtin/list", dashboardBuiltinList) + pages.GET("/builtin-boards", builtinBoardGets) + pages.GET("/builtin-board/:name", builtinBoardGet) + + pages.GET("/busi-group/:id/boards", jwtAuth(), user(), perm("/dashboards"), bgro(), boardGets) + pages.POST("/busi-group/:id/boards", jwtAuth(), user(), perm("/dashboards/add"), bgrw(), boardAdd) + pages.POST("/busi-group/:id/board/:bid/clone", jwtAuth(), user(), perm("/dashboards/add"), bgrw(), boardClone) + + pages.GET("/board/:bid", jwtAuth(), user(), boardGet) + pages.PUT("/board/:bid", jwtAuth(), user(), perm("/dashboards/put"), boardPut) + pages.PUT("/board/:bid/configs", jwtAuth(), user(), perm("/dashboards/put"), boardPutConfigs) + pages.DELETE("/boards", jwtAuth(), user(), perm("/dashboards/del"), boardDel) + + // migrate v5.8.0 + pages.GET("/dashboards", jwtAuth(), admin(), migrateDashboards) + pages.GET("/dashboard/:id", jwtAuth(), admin(), migrateDashboardGet) + pages.PUT("/dashboard/:id/migrate", jwtAuth(), admin(), migrateDashboard) + + // deprecated ↓ + pages.GET("/dashboards/builtin/list", builtinBoardGets) pages.POST("/busi-group/:id/dashboards/builtin", jwtAuth(), user(), perm("/dashboards/add"), bgrw(), dashboardBuiltinImport) pages.GET("/busi-group/:id/dashboards", jwtAuth(), user(), perm("/dashboards"), bgro(), dashboardGets) pages.POST("/busi-group/:id/dashboards", jwtAuth(), user(), perm("/dashboards/add"), bgrw(), dashboardAdd) @@ -186,6 +204,7 @@ func configRoute(r *gin.Engine, version string) { pages.POST("/busi-group/:id/charts", jwtAuth(), user(), bgrw(), chartAdd) pages.PUT("/busi-group/:id/charts", jwtAuth(), user(), bgrw(), chartPut) pages.DELETE("/busi-group/:id/charts", jwtAuth(), user(), bgrw(), chartDel) + // deprecated ↑ pages.GET("/share-charts", chartShareGets) pages.POST("/share-charts", jwtAuth(), chartShareAdd) @@ -193,10 +212,10 @@ func configRoute(r *gin.Engine, version string) { pages.GET("/alert-rules/builtin/list", alertRuleBuiltinList) pages.POST("/busi-group/:id/alert-rules/builtin", jwtAuth(), user(), perm("/alert-rules/add"), bgrw(), alertRuleBuiltinImport) pages.GET("/busi-group/:id/alert-rules", jwtAuth(), user(), perm("/alert-rules"), alertRuleGets) - pages.POST("/busi-group/:id/alert-rules", jwtAuth(), user(), perm("/alert-rules/add"), bgrw(), alertRuleAdd) + pages.POST("/busi-group/:id/alert-rules", jwtAuth(), user(), perm("/alert-rules/add"), bgrw(), alertRuleAddByFE) pages.DELETE("/busi-group/:id/alert-rules", jwtAuth(), user(), perm("/alert-rules/del"), bgrw(), alertRuleDel) pages.PUT("/busi-group/:id/alert-rules/fields", jwtAuth(), user(), perm("/alert-rules/put"), bgrw(), alertRulePutFields) - pages.PUT("/busi-group/:id/alert-rule/:arid", jwtAuth(), user(), perm("/alert-rules/put"), alertRulePut) + pages.PUT("/busi-group/:id/alert-rule/:arid", jwtAuth(), user(), perm("/alert-rules/put"), alertRulePutByFE) pages.GET("/alert-rule/:arid", jwtAuth(), user(), perm("/alert-rules"), alertRuleGet) pages.GET("/busi-group/:id/alert-mutes", jwtAuth(), user(), perm("/alert-mutes"), bgro(), alertMuteGets) @@ -252,11 +271,16 @@ func configRoute(r *gin.Engine, version string) { service.POST("/users", userAddPost) service.GET("/targets", targetGets) - service.DELETE("/targets", targetDel) service.GET("/targets/tags", targetGetTags) - service.POST("/targets/tags", targetBindTags) - service.DELETE("/targets/tags", targetUnbindTags) - service.PUT("/targets/note", targetUpdateNote) - service.PUT("/targets/bgid", targetUpdateBgid) + service.POST("/targets/tags", targetBindTagsByService) + service.DELETE("/targets/tags", targetUnbindTagsByService) + service.PUT("/targets/note", targetUpdateNoteByService) + + service.GET("/alert-rules", alertRuleGets) + service.POST("/alert-rules", alertRuleAddByService) + service.DELETE("/alert-rules", alertRuleDel) + service.PUT("/alert-rule", alertRulePutByService) + service.GET("/alert-rule/:arid", alertRuleGet) + service.GET("/alert-rules-get-by-prod", alertRulesGetByProds) } } diff --git a/src/webapi/router/router_alert_rule.go b/src/webapi/router/router_alert_rule.go index 644ef282..ce330083 100644 --- a/src/webapi/router/router_alert_rule.go +++ b/src/webapi/router/router_alert_rule.go @@ -2,6 +2,7 @@ package router import ( "net/http" + "strings" "time" "github.com/gin-gonic/gin" @@ -24,8 +25,24 @@ func alertRuleGets(c *gin.Context) { ginx.NewRender(c).Data(ars, err) } +func alertRulesGetByProds(c *gin.Context) { + prods := ginx.QueryStr(c, "prods", "") + arr := strings.Split(prods, ",") + + ars, err := models.AlertRulesGetByProds(arr) + if err == nil { + cache := make(map[int64]*models.UserGroup) + for i := 0; i < len(ars); i++ { + ars[i].FillNotifyGroups(cache) + } + } + ginx.NewRender(c).Data(ars, err) +} + // single or import -func alertRuleAdd(c *gin.Context) { +func alertRuleAddByFE(c *gin.Context) { + username := c.MustGet("username").(string) + var lst []models.AlertRule ginx.BindJSON(c, &lst) @@ -34,26 +51,48 @@ func alertRuleAdd(c *gin.Context) { ginx.Bomb(http.StatusBadRequest, "input json is empty") } - username := c.MustGet("username").(string) bgid := ginx.UrlParamInt64(c, "id") + reterr := alertRuleAdd(lst, username, bgid, c.GetHeader("X-Language")) + ginx.NewRender(c).Data(reterr, nil) +} + +func alertRuleAddByService(c *gin.Context) { + var lst []models.AlertRule + ginx.BindJSON(c, &lst) + + count := len(lst) + if count == 0 { + ginx.Bomb(http.StatusBadRequest, "input json is empty") + } + reterr := alertRuleAdd(lst, "", 0, c.GetHeader("X-Language")) + ginx.NewRender(c).Data(reterr, nil) +} + +func alertRuleAdd(lst []models.AlertRule, username string, bgid int64, lang string) map[string]string { + count := len(lst) // alert rule name -> error string reterr := make(map[string]string) for i := 0; i < count; i++ { lst[i].Id = 0 lst[i].GroupId = bgid - lst[i].CreateBy = username - lst[i].UpdateBy = username - lst[i].FE2DB() + if username != "" { + lst[i].CreateBy = username + lst[i].UpdateBy = username + } + + if err := lst[i].FE2DB(); err != nil { + reterr[lst[i].Name] = i18n.Sprintf(lang, err.Error()) + continue + } if err := lst[i].Add(); err != nil { - reterr[lst[i].Name] = i18n.Sprintf(c.GetHeader("X-Language"), err.Error()) + reterr[lst[i].Name] = i18n.Sprintf(lang, err.Error()) } else { reterr[lst[i].Name] = "" } } - - ginx.NewRender(c).Data(reterr, nil) + return reterr } func alertRuleDel(c *gin.Context) { @@ -65,7 +104,7 @@ func alertRuleDel(c *gin.Context) { ginx.NewRender(c).Message(models.AlertRuleDels(f.Ids, ginx.UrlParamInt64(c, "id"))) } -func alertRulePut(c *gin.Context) { +func alertRulePutByFE(c *gin.Context) { var f models.AlertRule ginx.BindJSON(c, &f) @@ -84,6 +123,21 @@ func alertRulePut(c *gin.Context) { ginx.NewRender(c).Message(ar.Update(f)) } +func alertRulePutByService(c *gin.Context) { + var f models.AlertRule + ginx.BindJSON(c, &f) + + arid := ginx.UrlParamInt64(c, "arid") + ar, err := models.AlertRuleGetById(arid) + ginx.Dangerous(err) + + if ar == nil { + ginx.NewRender(c, http.StatusNotFound).Message("No such AlertRule") + return + } + ginx.NewRender(c).Message(ar.Update(f)) +} + type alertRuleFieldForm struct { Ids []int64 `json:"ids"` Fields map[string]interface{} `json:"fields"` diff --git a/src/webapi/router/router_board.go b/src/webapi/router/router_board.go new file mode 100644 index 00000000..4f799d43 --- /dev/null +++ b/src/webapi/router/router_board.go @@ -0,0 +1,200 @@ +package router + +import ( + "net/http" + "time" + + "github.com/didi/nightingale/v5/src/models" + "github.com/gin-gonic/gin" + "github.com/toolkits/pkg/ginx" +) + +type boardForm struct { + Name string `json:"name"` + Tags string `json:"tags"` + Configs string `json:"configs"` +} + +func boardAdd(c *gin.Context) { + var f boardForm + ginx.BindJSON(c, &f) + + me := c.MustGet("user").(*models.User) + + board := &models.Board{ + GroupId: ginx.UrlParamInt64(c, "id"), + Name: f.Name, + Tags: f.Tags, + Configs: f.Configs, + CreateBy: me.Username, + UpdateBy: me.Username, + } + + err := board.Add() + ginx.Dangerous(err) + + if f.Configs != "" { + ginx.Dangerous(models.BoardPayloadSave(board.Id, f.Configs)) + } + + ginx.NewRender(c).Data(board, nil) +} + +func boardGet(c *gin.Context) { + board, err := models.BoardGet("id = ?", ginx.UrlParamInt64(c, "bid")) + ginx.Dangerous(err) + + if board == nil { + ginx.Bomb(http.StatusNotFound, "No such dashboard") + } + + ginx.NewRender(c).Data(board, nil) +} + +// bgrwCheck +func boardDel(c *gin.Context) { + var f idsForm + ginx.BindJSON(c, &f) + f.Verify() + + for i := 0; i < len(f.Ids); i++ { + bid := f.Ids[i] + + board, err := models.BoardGet("id = ?", bid) + ginx.Dangerous(err) + + if board == nil { + continue + } + + // check permission + bgrwCheck(c, board.GroupId) + + ginx.Dangerous(board.Del()) + } + + ginx.NewRender(c).Message(nil) +} + +func Board(id int64) *models.Board { + obj, err := models.BoardGet("id=?", id) + ginx.Dangerous(err) + + if obj == nil { + ginx.Bomb(http.StatusNotFound, "No such dashboard") + } + + return obj +} + +// bgrwCheck +func boardPut(c *gin.Context) { + var f boardForm + ginx.BindJSON(c, &f) + + me := c.MustGet("user").(*models.User) + bo := Board(ginx.UrlParamInt64(c, "bid")) + + // check permission + bgrwCheck(c, bo.GroupId) + + bo.Name = f.Name + bo.Tags = f.Tags + bo.UpdateBy = me.Username + bo.UpdateAt = time.Now().Unix() + + err := bo.Update("name", "tags", "update_by", "update_at") + ginx.NewRender(c).Data(bo, err) +} + +// bgrwCheck +func boardPutConfigs(c *gin.Context) { + var f boardForm + ginx.BindJSON(c, &f) + + me := c.MustGet("user").(*models.User) + bo := Board(ginx.UrlParamInt64(c, "bid")) + + // check permission + bgrwCheck(c, bo.GroupId) + + bo.UpdateBy = me.Username + bo.UpdateAt = time.Now().Unix() + ginx.Dangerous(bo.Update("update_by", "update_at")) + + bo.Configs = f.Configs + ginx.Dangerous(models.BoardPayloadSave(bo.Id, f.Configs)) + + ginx.NewRender(c).Data(bo, nil) +} + +func boardGets(c *gin.Context) { + bgid := ginx.UrlParamInt64(c, "id") + query := ginx.QueryStr(c, "query", "") + + boards, err := models.BoardGets(bgid, query) + ginx.NewRender(c).Data(boards, err) +} + +func boardClone(c *gin.Context) { + me := c.MustGet("user").(*models.User) + bo := Board(ginx.UrlParamInt64(c, "bid")) + + newBoard := &models.Board{ + Name: bo.Name + " Copy", + Tags: bo.Tags, + GroupId: bo.GroupId, + CreateBy: me.Username, + UpdateBy: me.Username, + } + + ginx.Dangerous(newBoard.Add()) + + // clone payload + payload, err := models.BoardPayloadGet(bo.Id) + ginx.Dangerous(err) + + if payload != "" { + ginx.Dangerous(models.BoardPayloadSave(newBoard.Id, payload)) + } + + ginx.NewRender(c).Message(nil) +} + +// ---- migrate ---- + +func migrateDashboards(c *gin.Context) { + lst, err := models.DashboardGetAll() + ginx.NewRender(c).Data(lst, err) +} + +func migrateDashboardGet(c *gin.Context) { + dash := Dashboard(ginx.UrlParamInt64(c, "id")) + ginx.NewRender(c).Data(dash, nil) +} + +func migrateDashboard(c *gin.Context) { + dash := Dashboard(ginx.UrlParamInt64(c, "id")) + + var f boardForm + ginx.BindJSON(c, &f) + + me := c.MustGet("user").(*models.User) + + board := &models.Board{ + GroupId: dash.GroupId, + Name: f.Name, + Tags: f.Tags, + Configs: f.Configs, + CreateBy: me.Username, + UpdateBy: me.Username, + } + + ginx.Dangerous(board.Add()) + + if board.Configs != "" { + ginx.Dangerous(models.BoardPayloadSave(board.Id, board.Configs)) + } + + ginx.NewRender(c).Message(dash.Del()) +} diff --git a/src/webapi/router/router_builtin.go b/src/webapi/router/router_builtin.go index 73b627a3..578c2389 100644 --- a/src/webapi/router/router_builtin.go +++ b/src/webapi/router/router_builtin.go @@ -75,7 +75,11 @@ func alertRuleBuiltinImport(c *gin.Context) { lst[i].GroupId = bgid lst[i].CreateBy = username lst[i].UpdateBy = username - lst[i].FE2DB() + + if err := lst[i].FE2DB(); err != nil { + reterr[lst[i].Name] = i18n.Sprintf(c.GetHeader("X-Language"), err.Error()) + continue + } if err := lst[i].Add(); err != nil { reterr[lst[i].Name] = i18n.Sprintf(c.GetHeader("X-Language"), err.Error()) @@ -87,7 +91,7 @@ func alertRuleBuiltinImport(c *gin.Context) { ginx.NewRender(c).Data(reterr, nil) } -func dashboardBuiltinList(c *gin.Context) { +func builtinBoardGets(c *gin.Context) { fp := config.C.BuiltinDashboardsDir if fp == "" { fp = path.Join(runner.Cwd, "etc", "dashboards") @@ -110,6 +114,25 @@ func dashboardBuiltinList(c *gin.Context) { ginx.NewRender(c).Data(names, nil) } +// read the json file content +func builtinBoardGet(c *gin.Context) { + name := ginx.UrlParamStr(c, "name") + dirpath := config.C.BuiltinDashboardsDir + if dirpath == "" { + dirpath = path.Join(runner.Cwd, "etc", "dashboards") + } + + jsonfile := path.Join(dirpath, name+".json") + if !file.IsExist(jsonfile) { + ginx.Bomb(http.StatusBadRequest, "%s not found", jsonfile) + } + + body, err := file.ReadString(jsonfile) + ginx.NewRender(c).Data(body, err) +} + +// deprecated ↓ + type dashboardBuiltinImportForm struct { Name string `json:"name" binding:"required"` } diff --git a/src/webapi/router/router_mw.go b/src/webapi/router/router_mw.go index 2b6193ed..6b1e5e46 100644 --- a/src/webapi/router/router_mw.go +++ b/src/webapi/router/router_mw.go @@ -153,6 +153,20 @@ func bgrwChecks(c *gin.Context, bgids []int64) { } } +func bgroCheck(c *gin.Context, bgid int64) { + me := c.MustGet("user").(*models.User) + bg := BusiGroup(bgid) + + can, err := me.CanDoBusiGroup(bg, "ro") + ginx.Dangerous(err) + + if !can { + ginx.Bomb(http.StatusForbidden, "forbidden") + } + + c.Set("busi_group", bg) +} + func perm(operation string) gin.HandlerFunc { return func(c *gin.Context) { me := c.MustGet("user").(*models.User) diff --git a/src/webapi/router/router_target.go b/src/webapi/router/router_target.go index 4c322964..5af9e81c 100644 --- a/src/webapi/router/router_target.go +++ b/src/webapi/router/router_target.go @@ -1,6 +1,7 @@ package router import ( + "fmt" "net/http" "strings" @@ -48,7 +49,11 @@ type targetTagsForm struct { Tags []string `json:"tags" binding:"required"` } -func targetBindTags(c *gin.Context) { +func (t targetTagsForm) Verify() { + +} + +func targetBindTagsByFE(c *gin.Context) { var f targetTagsForm ginx.BindJSON(c, &f) @@ -58,33 +63,49 @@ func targetBindTags(c *gin.Context) { checkTargetPerm(c, f.Idents) - // verify + ginx.NewRender(c).Message(targetBindTags(f)) +} + +func targetBindTagsByService(c *gin.Context) { + var f targetTagsForm + ginx.BindJSON(c, &f) + + if len(f.Idents) == 0 { + ginx.Bomb(http.StatusBadRequest, "idents empty") + } + + ginx.NewRender(c).Message(targetBindTags(f)) +} + +func targetBindTags(f targetTagsForm) error { for i := 0; i < len(f.Tags); i++ { arr := strings.Split(f.Tags[i], "=") if len(arr) != 2 { - ginx.Bomb(200, "invalid tag(%s)", f.Tags[i]) + return fmt.Errorf("invalid tag(%s)", f.Tags[i]) } if strings.TrimSpace(arr[0]) == "" || strings.TrimSpace(arr[1]) == "" { - ginx.Bomb(200, "invalid tag(%s)", f.Tags[i]) + return fmt.Errorf("invalid tag(%s)", f.Tags[i]) } if strings.IndexByte(arr[0], '.') != -1 { - ginx.Bomb(200, "invalid tagkey(%s): cannot contains .", arr[0]) + return fmt.Errorf("invalid tagkey(%s): cannot contains . ", arr[0]) } if strings.IndexByte(arr[0], '-') != -1 { - ginx.Bomb(200, "invalid tagkey(%s): cannot contains -", arr[0]) + return fmt.Errorf("invalid tagkey(%s): cannot contains -", arr[0]) } if !model.LabelNameRE.MatchString(arr[0]) { - ginx.Bomb(200, "invalid tagkey(%s)", arr[0]) + return fmt.Errorf("invalid tagkey(%s)", arr[0]) } } for i := 0; i < len(f.Idents); i++ { target, err := models.TargetGetByIdent(f.Idents[i]) - ginx.Dangerous(err) + if err != nil { + return err + } if target == nil { continue @@ -95,18 +116,19 @@ func targetBindTags(c *gin.Context) { tagkey := strings.Split(f.Tags[j], "=")[0] tagkeyPrefix := tagkey + "=" if strings.HasPrefix(target.Tags, tagkeyPrefix) { - ginx.NewRender(c).Message("duplicate tagkey(%s)", tagkey) - return + return fmt.Errorf("duplicate tagkey(%s)", tagkey) } } - ginx.Dangerous(target.AddTags(f.Tags)) + err = target.AddTags(f.Tags) + if err != nil { + return err + } } - - ginx.NewRender(c).Message(nil) + return nil } -func targetUnbindTags(c *gin.Context) { +func targetUnbindTagsByFE(c *gin.Context) { var f targetTagsForm ginx.BindJSON(c, &f) @@ -116,18 +138,37 @@ func targetUnbindTags(c *gin.Context) { checkTargetPerm(c, f.Idents) + ginx.NewRender(c).Message(targetUnbindTags(f)) +} + +func targetUnbindTagsByService(c *gin.Context) { + var f targetTagsForm + ginx.BindJSON(c, &f) + + if len(f.Idents) == 0 { + ginx.Bomb(http.StatusBadRequest, "idents empty") + } + + ginx.NewRender(c).Message(targetUnbindTags(f)) +} + +func targetUnbindTags(f targetTagsForm) error { for i := 0; i < len(f.Idents); i++ { target, err := models.TargetGetByIdent(f.Idents[i]) - ginx.Dangerous(err) + if err != nil { + return err + } if target == nil { continue } - ginx.Dangerous(target.DelTags(f.Tags)) + err = target.DelTags(f.Tags) + if err != nil { + return err + } } - - ginx.NewRender(c).Message(nil) + return nil } type targetNoteForm struct { @@ -148,6 +189,17 @@ func targetUpdateNote(c *gin.Context) { ginx.NewRender(c).Message(models.TargetUpdateNote(f.Idents, f.Note)) } +func targetUpdateNoteByService(c *gin.Context) { + var f targetNoteForm + ginx.BindJSON(c, &f) + + if len(f.Idents) == 0 { + ginx.Bomb(http.StatusBadRequest, "idents empty") + } + + ginx.NewRender(c).Message(models.TargetUpdateNote(f.Idents, f.Note)) +} + type targetBgidForm struct { Idents []string `json:"idents" binding:"required"` Bgid int64 `json:"bgid"` diff --git a/src/webapi/webapi.go b/src/webapi/webapi.go index 46f401d3..4c84f549 100644 --- a/src/webapi/webapi.go +++ b/src/webapi/webapi.go @@ -101,11 +101,7 @@ func (a Webapi) initialize() (func(), error) { } // init database - if err = storage.InitDB(storage.DBConfig{ - Gorm: config.C.Gorm, - MySQL: config.C.MySQL, - Postgres: config.C.Postgres, - }); err != nil { + if err = storage.InitDB(config.C.DB); err != nil { return nil, err }