diff --git a/go.mod b/go.mod index 7b565e12..9f111413 100644 --- a/go.mod +++ b/go.mod @@ -113,7 +113,7 @@ require ( github.com/cloudflare/cloudflare-go v0.104.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/disintegration/imaging v1.6.2 // indirect - github.com/domodwyer/mailyak/v3 v3.6.2 // indirect + github.com/domodwyer/mailyak/v3 v3.6.2 github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.17.0 // indirect github.com/gabriel-vasile/mimetype v1.4.6 // indirect diff --git a/internal/domain/domains.go b/internal/domain/domains.go index b679625f..2bc07074 100644 --- a/internal/domain/domains.go +++ b/internal/domain/domains.go @@ -3,6 +3,8 @@ package domain import ( "encoding/json" "strings" + + "github.com/usual2970/certimate/internal/pkg/utils/maps" ) type ApplyConfig struct { @@ -29,7 +31,7 @@ type DeployConfig struct { // 出参: // - 配置项的值。如果配置项不存在或者类型不是字符串,则返回空字符串。 func (dc *DeployConfig) GetConfigAsString(key string) string { - return dc.GetConfigOrDefaultAsString(key, "") + return maps.GetValueAsString(dc.Config, key) } // 以字符串形式获取配置项。 @@ -41,17 +43,7 @@ func (dc *DeployConfig) GetConfigAsString(key string) string { // 出参: // - 配置项的值。如果配置项不存在或者类型不是字符串,则返回默认值。 func (dc *DeployConfig) GetConfigOrDefaultAsString(key string, defaultValue string) string { - if dc.Config == nil { - return defaultValue - } - - if value, ok := dc.Config[key]; ok { - if result, ok := value.(string); ok { - return result - } - } - - return defaultValue + return maps.GetValueOrDefaultAsString(dc.Config, key, defaultValue) } // 以 32 位整数形式获取配置项。 @@ -62,7 +54,7 @@ func (dc *DeployConfig) GetConfigOrDefaultAsString(key string, defaultValue stri // 出参: // - 配置项的值。如果配置项不存在或者类型不是 32 位整数,则返回 0。 func (dc *DeployConfig) GetConfigAsInt32(key string) int32 { - return dc.GetConfigOrDefaultAsInt32(key, 0) + return maps.GetValueAsInt32(dc.Config, key) } // 以 32 位整数形式获取配置项。 @@ -74,17 +66,7 @@ func (dc *DeployConfig) GetConfigAsInt32(key string) int32 { // 出参: // - 配置项的值。如果配置项不存在或者类型不是 32 位整数,则返回默认值。 func (dc *DeployConfig) GetConfigOrDefaultAsInt32(key string, defaultValue int32) int32 { - if dc.Config == nil { - return defaultValue - } - - if value, ok := dc.Config[key]; ok { - if result, ok := value.(int32); ok { - return result - } - } - - return defaultValue + return maps.GetValueOrDefaultAsInt32(dc.Config, key, defaultValue) } // 以布尔形式获取配置项。 @@ -95,7 +77,7 @@ func (dc *DeployConfig) GetConfigOrDefaultAsInt32(key string, defaultValue int32 // 出参: // - 配置项的值。如果配置项不存在或者类型不是布尔,则返回 false。 func (dc *DeployConfig) GetConfigAsBool(key string) bool { - return dc.GetConfigOrDefaultAsBool(key, false) + return maps.GetValueAsBool(dc.Config, key) } // 以布尔形式获取配置项。 @@ -107,17 +89,7 @@ func (dc *DeployConfig) GetConfigAsBool(key string) bool { // 出参: // - 配置项的值。如果配置项不存在或者类型不是布尔,则返回默认值。 func (dc *DeployConfig) GetConfigOrDefaultAsBool(key string, defaultValue bool) bool { - if dc.Config == nil { - return defaultValue - } - - if value, ok := dc.Config[key]; ok { - if result, ok := value.(bool); ok { - return result - } - } - - return defaultValue + return maps.GetValueOrDefaultAsBool(dc.Config, key, defaultValue) } // 以变量字典形式获取配置项。 diff --git a/internal/domain/notify.go b/internal/domain/notify.go index 440bbef7..6c164f39 100644 --- a/internal/domain/notify.go +++ b/internal/domain/notify.go @@ -1,12 +1,12 @@ package domain const ( - NotifyChannelDingtalk = "dingtalk" + NotifyChannelEmail = "email" NotifyChannelWebhook = "webhook" - NotifyChannelTelegram = "telegram" + NotifyChannelDingtalk = "dingtalk" NotifyChannelLark = "lark" + NotifyChannelTelegram = "telegram" NotifyChannelServerChan = "serverchan" - NotifyChannelMail = "mail" NotifyChannelBark = "bark" ) diff --git a/internal/domain/setting.go b/internal/domain/setting.go index a704805d..ec36be8a 100644 --- a/internal/domain/setting.go +++ b/internal/domain/setting.go @@ -24,7 +24,7 @@ func (s *Setting) GetChannelContent(channel string) (map[string]any, error) { v, ok := (*conf)[channel] if !ok { - return nil, fmt.Errorf("channel %s not found", channel) + return nil, fmt.Errorf("channel \"%s\" not found", channel) } return v, nil diff --git a/internal/notify/expire.go b/internal/notify/expire.go index d4942272..5dc424ce 100644 --- a/internal/notify/expire.go +++ b/internal/notify/expire.go @@ -12,19 +12,13 @@ import ( "github.com/usual2970/certimate/internal/utils/xtime" ) -type msg struct { - subject string - message string -} - const ( - defaultExpireSubject = "您有{COUNT}张证书即将过期" - defaultExpireMsg = "有{COUNT}张证书即将过期,域名分别为{DOMAINS},请保持关注!" + defaultExpireSubject = "您有 {COUNT} 张证书即将过期" + defaultExpireMessage = "有 {COUNT} 张证书即将过期,域名分别为 {DOMAINS},请保持关注!" ) func PushExpireMsg() { // 查询即将过期的证书 - records, err := app.GetApp().Dao().FindRecordsByFilter("domains", "expiredAt<{:time}&&certUrl!=''", "-created", 500, 0, dbx.Params{"time": xtime.GetTimeAfter(24 * time.Hour * 15)}) if err != nil { @@ -34,12 +28,12 @@ func PushExpireMsg() { // 组装消息 msg := buildMsg(records) - if msg == nil { return } - if err := Send(msg.subject, msg.message); err != nil { + // 发送通知 + if err := SendToAllChannels(msg.Subject, msg.Message); err != nil { app.GetApp().Logger().Error("send expire msg", "error", err) } } @@ -53,22 +47,27 @@ type notifyTemplate struct { Content string `json:"content"` } -func buildMsg(records []*models.Record) *msg { +type notifyMessage struct { + Subject string + Message string +} + +func buildMsg(records []*models.Record) *notifyMessage { if len(records) == 0 { return nil } // 查询模板信息 templateRecord, err := app.GetApp().Dao().FindFirstRecordByFilter("settings", "name='templates'") - title := defaultExpireSubject - content := defaultExpireMsg + subject := defaultExpireSubject + message := defaultExpireMessage if err == nil { var templates *notifyTemplates templateRecord.UnmarshalJSONField("content", templates) if templates != nil && len(templates.NotifyTemplates) > 0 { - title = templates.NotifyTemplates[0].Title - content = templates.NotifyTemplates[0].Content + subject = templates.NotifyTemplates[0].Title + message = templates.NotifyTemplates[0].Content } } @@ -81,17 +80,17 @@ func buildMsg(records []*models.Record) *msg { } countStr := strconv.Itoa(count) - domainStr := strings.Join(domains, ",") + domainStr := strings.Join(domains, ";") - title = strings.ReplaceAll(title, "{COUNT}", countStr) - title = strings.ReplaceAll(title, "{DOMAINS}", domainStr) + subject = strings.ReplaceAll(subject, "{COUNT}", countStr) + subject = strings.ReplaceAll(subject, "{DOMAINS}", domainStr) - content = strings.ReplaceAll(content, "{COUNT}", countStr) - content = strings.ReplaceAll(content, "{DOMAINS}", domainStr) + message = strings.ReplaceAll(message, "{COUNT}", countStr) + message = strings.ReplaceAll(message, "{DOMAINS}", domainStr) // 返回消息 - return &msg{ - subject: title, - message: content, + return ¬ifyMessage{ + Subject: subject, + Message: message, } } diff --git a/internal/notify/factory.go b/internal/notify/factory.go new file mode 100644 index 00000000..ccdd5389 --- /dev/null +++ b/internal/notify/factory.go @@ -0,0 +1,66 @@ +package notify + +import ( + "errors" + + "github.com/usual2970/certimate/internal/domain" + "github.com/usual2970/certimate/internal/pkg/core/notifier" + notifierBark "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/bark" + notifierDingTalk "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/dingtalk" + notifierEmail "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/email" + notifierLark "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/lark" + notifierServerChan "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/serverchan" + notifierTelegram "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/telegram" + notifierWebhook "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/webhook" + "github.com/usual2970/certimate/internal/pkg/utils/maps" +) + +func createNotifier(channel string, channelConfig map[string]any) (notifier.Notifier, error) { + switch channel { + case domain.NotifyChannelEmail: + return notifierEmail.New(¬ifierEmail.EmailNotifierConfig{ + SmtpHost: maps.GetValueAsString(channelConfig, "smtpHost"), + SmtpPort: maps.GetValueAsInt32(channelConfig, "smtpPort"), + SmtpTLS: maps.GetValueOrDefaultAsBool(channelConfig, "smtpTLS", true), + Username: maps.GetValueOrDefaultAsString(channelConfig, "username", maps.GetValueAsString(channelConfig, "senderAddress")), + Password: maps.GetValueAsString(channelConfig, "password"), + SenderAddress: maps.GetValueAsString(channelConfig, "senderAddress"), + ReceiverAddress: maps.GetValueAsString(channelConfig, "receiverAddress"), + }) + + case domain.NotifyChannelWebhook: + return notifierWebhook.New(¬ifierWebhook.WebhookNotifierConfig{ + Url: maps.GetValueAsString(channelConfig, "url"), + }) + + case domain.NotifyChannelDingtalk: + return notifierDingTalk.New(¬ifierDingTalk.DingTalkNotifierConfig{ + AccessToken: maps.GetValueAsString(channelConfig, "accessToken"), + Secret: maps.GetValueAsString(channelConfig, "secret"), + }) + + case domain.NotifyChannelLark: + return notifierLark.New(¬ifierLark.LarkNotifierConfig{ + WebhookUrl: maps.GetValueAsString(channelConfig, "webhookUrl"), + }) + + case domain.NotifyChannelTelegram: + return notifierTelegram.New(¬ifierTelegram.TelegramNotifierConfig{ + ApiToken: maps.GetValueAsString(channelConfig, "apiToken"), + ChatId: maps.GetValueAsInt64(channelConfig, "chatId"), + }) + + case domain.NotifyChannelServerChan: + return notifierServerChan.New(¬ifierServerChan.ServerChanNotifierConfig{ + Url: maps.GetValueAsString(channelConfig, "url"), + }) + + case domain.NotifyChannelBark: + return notifierBark.New(¬ifierBark.BarkNotifierConfig{ + DeviceKey: maps.GetValueAsString(channelConfig, "deviceKey"), + ServerUrl: maps.GetValueAsString(channelConfig, "serverUrl"), + }) + } + + return nil, errors.New("unsupported notifier channel") +} diff --git a/internal/notify/mail.go b/internal/notify/mail.go deleted file mode 100644 index 060ffcd6..00000000 --- a/internal/notify/mail.go +++ /dev/null @@ -1,56 +0,0 @@ -package notify - -import ( - "context" - "fmt" - "net/mail" - "strconv" - - "github.com/pocketbase/pocketbase/tools/mailer" -) - -const defaultSmtpHostPort = "25" - -type Mail struct { - username string - to string - client *mailer.SmtpClient -} - -func NewMail(senderAddress, receiverAddresses, smtpHostAddr, smtpHostPort, password string) (*Mail, error) { - if smtpHostPort == "" { - smtpHostPort = defaultSmtpHostPort - } - - port, err := strconv.Atoi(smtpHostPort) - if err != nil { - return nil, fmt.Errorf("invalid smtp port: %w", err) - } - - client := mailer.SmtpClient{ - Host: smtpHostAddr, - Port: port, - Username: senderAddress, - Password: password, - Tls: true, - } - - return &Mail{ - username: senderAddress, - client: &client, - to: receiverAddresses, - }, nil -} - -func (m *Mail) Send(ctx context.Context, subject, content string) error { - message := &mailer.Message{ - From: mail.Address{ - Address: m.username, - }, - To: []mail.Address{{Address: m.to}}, - Subject: subject, - Text: content, - } - - return m.client.Send(message) -} diff --git a/internal/notify/notify.go b/internal/notify/notify.go index f414b6d4..6b218a18 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -3,24 +3,16 @@ package notify import ( "context" "fmt" - "strconv" - stdhttp "net/http" + "golang.org/x/sync/errgroup" - "github.com/usual2970/certimate/internal/domain" + "github.com/usual2970/certimate/internal/pkg/core/notifier" + "github.com/usual2970/certimate/internal/pkg/utils/maps" "github.com/usual2970/certimate/internal/utils/app" - - notifyPackage "github.com/nikoksr/notify" - "github.com/nikoksr/notify/service/bark" - "github.com/nikoksr/notify/service/dingding" - "github.com/nikoksr/notify/service/http" - "github.com/nikoksr/notify/service/lark" - "github.com/nikoksr/notify/service/telegram" ) -func Send(title, content string) error { - // 获取所有的推送渠道 - notifiers, err := getNotifiers() +func SendToAllChannels(subject, message string) error { + notifiers, err := getEnabledNotifiers() if err != nil { return err } @@ -28,184 +20,56 @@ func Send(title, content string) error { return nil } - n := notifyPackage.New() - // 添加推送渠道 - n.UseServices(notifiers...) + var eg errgroup.Group + for _, n := range notifiers { + if n == nil { + continue + } - // 发送消息 - return n.Send(context.Background(), title, content) + eg.Go(func() error { + _, err := n.Notify(context.Background(), subject, message) + return err + }) + } + + err = eg.Wait() + return err } -type sendTestParam struct { - Title string `json:"title"` - Content string `json:"content"` - Channel string `json:"channel"` - Conf map[string]any `json:"conf"` -} - -func SendTest(param *sendTestParam) error { - notifier, err := getNotifier(param.Channel, param.Conf) +func SendToChannel(subject, message string, channel string, channelConfig map[string]any) error { + notifier, err := createNotifier(channel, channelConfig) if err != nil { return err } - n := notifyPackage.New() - - // 添加推送渠道 - n.UseServices(notifier) - - // 发送消息 - return n.Send(context.Background(), param.Title, param.Content) + _, err = notifier.Notify(context.Background(), subject, message) + return err } -func getNotifiers() ([]notifyPackage.Notifier, error) { - resp, err := app.GetApp().Dao().FindFirstRecordByFilter("settings", "name='notifyChannels'") +func getEnabledNotifiers() ([]notifier.Notifier, error) { + settings, err := app.GetApp().Dao().FindFirstRecordByFilter("settings", "name='notifyChannels'") if err != nil { return nil, fmt.Errorf("find notifyChannels error: %w", err) } - notifiers := make([]notifyPackage.Notifier, 0) - rs := make(map[string]map[string]any) - - if err := resp.UnmarshalJSONField("content", &rs); err != nil { + if err := settings.UnmarshalJSONField("content", &rs); err != nil { return nil, fmt.Errorf("unmarshal notifyChannels error: %w", err) } + notifiers := make([]notifier.Notifier, 0) for k, v := range rs { - - if !getBool(v, "enabled") { + if !maps.GetValueAsBool(v, "enabled") { continue } - notifier, err := getNotifier(k, v) + notifier, err := createNotifier(k, v) if err != nil { continue } notifiers = append(notifiers, notifier) - } return notifiers, nil } - -func getNotifier(channel string, conf map[string]any) (notifyPackage.Notifier, error) { - switch channel { - case domain.NotifyChannelTelegram: - temp := getTelegramNotifier(conf) - if temp == nil { - return nil, fmt.Errorf("telegram notifier config error") - } - - return temp, nil - case domain.NotifyChannelDingtalk: - return getDingTalkNotifier(conf), nil - case domain.NotifyChannelLark: - return getLarkNotifier(conf), nil - case domain.NotifyChannelWebhook: - return getWebhookNotifier(conf), nil - case domain.NotifyChannelServerChan: - return getServerChanNotifier(conf), nil - case domain.NotifyChannelMail: - return getMailNotifier(conf) - case domain.NotifyChannelBark: - return getBarkNotifier(conf), nil - } - - return nil, fmt.Errorf("notifier not found") -} - -func getWebhookNotifier(conf map[string]any) notifyPackage.Notifier { - rs := http.New() - - rs.AddReceiversURLs(getString(conf, "url")) - - return rs -} - -func getTelegramNotifier(conf map[string]any) notifyPackage.Notifier { - rs, err := telegram.New(getString(conf, "apiToken")) - if err != nil { - return nil - } - - chatId := getString(conf, "chatId") - - id, err := strconv.ParseInt(chatId, 10, 64) - if err != nil { - return nil - } - - rs.AddReceivers(id) - return rs -} - -func getServerChanNotifier(conf map[string]any) notifyPackage.Notifier { - rs := http.New() - - rs.AddReceivers(&http.Webhook{ - URL: getString(conf, "url"), - Header: stdhttp.Header{}, - ContentType: "application/json", - Method: stdhttp.MethodPost, - BuildPayload: func(subject, message string) (payload any) { - return map[string]string{ - "text": subject, - "desp": message, - } - }, - }) - - return rs -} - -func getBarkNotifier(conf map[string]any) notifyPackage.Notifier { - deviceKey := getString(conf, "deviceKey") - serverURL := getString(conf, "serverUrl") - if serverURL == "" { - return bark.New(deviceKey) - } - return bark.NewWithServers(deviceKey, serverURL) -} - -func getDingTalkNotifier(conf map[string]any) notifyPackage.Notifier { - return dingding.New(&dingding.Config{ - Token: getString(conf, "accessToken"), - Secret: getString(conf, "secret"), - }) -} - -func getLarkNotifier(conf map[string]any) notifyPackage.Notifier { - return lark.NewWebhookService(getString(conf, "webhookUrl")) -} - -func getMailNotifier(conf map[string]any) (notifyPackage.Notifier, error) { - rs, err := NewMail(getString(conf, "senderAddress"), - getString(conf, "receiverAddresses"), - getString(conf, "smtpHostAddr"), - getString(conf, "smtpHostPort"), - getString(conf, "password"), - ) - if err != nil { - return nil, err - } - - return rs, nil -} - -func getString(conf map[string]any, key string) string { - if _, ok := conf[key]; !ok { - return "" - } - - return conf[key].(string) -} - -func getBool(conf map[string]any, key string) bool { - if _, ok := conf[key]; !ok { - return false - } - - return conf[key].(bool) -} diff --git a/internal/notify/service.go b/internal/notify/service.go index 22b77160..76162ff1 100644 --- a/internal/notify/service.go +++ b/internal/notify/service.go @@ -29,18 +29,13 @@ func NewNotifyService(settingRepo SettingRepository) *NotifyService { func (n *NotifyService) Test(ctx context.Context, req *domain.NotifyTestPushReq) error { setting, err := n.settingRepo.GetByName(ctx, "notifyChannels") if err != nil { - return fmt.Errorf("get notify channels setting failed: %w", err) + return fmt.Errorf("failed to get notify channels settings: %w", err) } - conf, err := setting.GetChannelContent(req.Channel) + channelConfig, err := setting.GetChannelContent(req.Channel) if err != nil { - return fmt.Errorf("get notify channel %s config failed: %w", req.Channel, err) + return fmt.Errorf("failed to get notify channel \"%s\" config: %w", req.Channel, err) } - return SendTest(&sendTestParam{ - Title: notifyTestTitle, - Content: notifyTestBody, - Channel: req.Channel, - Conf: conf, - }) + return SendToChannel(notifyTestTitle, notifyTestBody, req.Channel, channelConfig) } diff --git a/internal/pkg/core/notifier/notifier.go b/internal/pkg/core/notifier/notifier.go new file mode 100644 index 00000000..d8819395 --- /dev/null +++ b/internal/pkg/core/notifier/notifier.go @@ -0,0 +1,23 @@ +package notifier + +import "context" + +// 表示定义消息通知器的抽象类型接口。 +type Notifier interface { + // 发送通知。 + // + // 入参: + // - ctx:上下文。 + // - subject:通知主题。 + // - message:通知内容。 + // + // 出参: + // - res:发送结果。 + // - err: 错误。 + Notify(ctx context.Context, subject string, message string) (res *NotifyResult, err error) +} + +// 表示通知发送结果的数据结构。 +type NotifyResult struct { + NotificationData map[string]any `json:"notificationData,omitempty"` +} diff --git a/internal/pkg/core/notifier/providers/bark/bark.go b/internal/pkg/core/notifier/providers/bark/bark.go new file mode 100644 index 00000000..cd8119e1 --- /dev/null +++ b/internal/pkg/core/notifier/providers/bark/bark.go @@ -0,0 +1,48 @@ +package bark + +import ( + "context" + "errors" + + "github.com/nikoksr/notify" + "github.com/nikoksr/notify/service/bark" + + "github.com/usual2970/certimate/internal/pkg/core/notifier" +) + +type BarkNotifierConfig struct { + ServerUrl string `json:"serverUrl"` + DeviceKey string `json:"deviceKey"` +} + +type BarkNotifier struct { + config *BarkNotifierConfig +} + +var _ notifier.Notifier = (*BarkNotifier)(nil) + +func New(config *BarkNotifierConfig) (*BarkNotifier, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + return &BarkNotifier{ + config: config, + }, nil +} + +func (n *BarkNotifier) Notify(ctx context.Context, subject string, message string) (res *notifier.NotifyResult, err error) { + var srv notify.Notifier + if n.config.ServerUrl == "" { + srv = bark.New(n.config.DeviceKey) + } else { + srv = bark.NewWithServers(n.config.DeviceKey, n.config.ServerUrl) + } + + err = srv.Send(ctx, subject, message) + if err != nil { + return nil, err + } + + return ¬ifier.NotifyResult{}, nil +} diff --git a/internal/pkg/core/notifier/providers/dingtalk/dingtalk.go b/internal/pkg/core/notifier/providers/dingtalk/dingtalk.go new file mode 100644 index 00000000..9ca1fcac --- /dev/null +++ b/internal/pkg/core/notifier/providers/dingtalk/dingtalk.go @@ -0,0 +1,45 @@ +package dingtalk + +import ( + "context" + "errors" + + "github.com/nikoksr/notify/service/dingding" + + "github.com/usual2970/certimate/internal/pkg/core/notifier" +) + +type DingTalkNotifierConfig struct { + AccessToken string `json:"accessToken"` + Secret string `json:"secret"` +} + +type DingTalkNotifier struct { + config *DingTalkNotifierConfig +} + +var _ notifier.Notifier = (*DingTalkNotifier)(nil) + +func New(config *DingTalkNotifierConfig) (*DingTalkNotifier, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + return &DingTalkNotifier{ + config: config, + }, nil +} + +func (n *DingTalkNotifier) Notify(ctx context.Context, subject string, message string) (res *notifier.NotifyResult, err error) { + srv := dingding.New(&dingding.Config{ + Token: n.config.AccessToken, + Secret: n.config.Secret, + }) + + err = srv.Send(ctx, subject, message) + if err != nil { + return nil, err + } + + return ¬ifier.NotifyResult{}, nil +} diff --git a/internal/pkg/core/notifier/providers/email/email.go b/internal/pkg/core/notifier/providers/email/email.go new file mode 100644 index 00000000..b79618cc --- /dev/null +++ b/internal/pkg/core/notifier/providers/email/email.go @@ -0,0 +1,95 @@ +package email + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net/smtp" + + "github.com/domodwyer/mailyak/v3" + + "github.com/usual2970/certimate/internal/pkg/core/notifier" +) + +type EmailNotifierConfig struct { + SmtpHost string `json:"smtpHost"` + SmtpPort int32 `json:"smtpPort"` + SmtpTLS bool `json:"smtpTLS"` + Username string `json:"username"` + Password string `json:"password"` + SenderAddress string `json:"senderAddress"` + ReceiverAddress string `json:"receiverAddress"` +} + +type EmailNotifier struct { + config *EmailNotifierConfig +} + +var _ notifier.Notifier = (*EmailNotifier)(nil) + +func New(config *EmailNotifierConfig) (*EmailNotifier, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + return &EmailNotifier{ + config: config, + }, nil +} + +func (n *EmailNotifier) Notify(ctx context.Context, subject string, message string) (res *notifier.NotifyResult, err error) { + var smtpAuth smtp.Auth + if n.config.Username != "" || n.config.Password != "" { + smtpAuth = smtp.PlainAuth("", n.config.Username, n.config.Password, n.config.SmtpHost) + } + + var smtpAddr string + if n.config.SmtpPort == 0 { + if n.config.SmtpTLS { + smtpAddr = fmt.Sprintf("%s:465", n.config.SmtpHost) + } else { + smtpAddr = fmt.Sprintf("%s:25", n.config.SmtpHost) + } + } else { + smtpAddr = fmt.Sprintf("%s:%d", n.config.SmtpHost, n.config.SmtpPort) + } + + var yak *mailyak.MailYak + if n.config.SmtpTLS { + yak, err = mailyak.NewWithTLS(smtpAddr, smtpAuth, newTlsConfig()) + if err != nil { + return nil, err + } + } else { + yak = mailyak.New(smtpAddr, smtpAuth) + } + + yak.From(n.config.SenderAddress) + yak.To(n.config.ReceiverAddress) + yak.Subject(subject) + yak.Plain().Set(message) + + err = yak.Send() + if err != nil { + return nil, err + } + + return ¬ifier.NotifyResult{}, nil +} + +func newTlsConfig() *tls.Config { + var suiteIds []uint16 + for _, suite := range tls.CipherSuites() { + suiteIds = append(suiteIds, suite.ID) + } + for _, suite := range tls.InsecureCipherSuites() { + suiteIds = append(suiteIds, suite.ID) + } + + // 为兼容国内部分低版本 TLS 的 SMTP 服务商 + return &tls.Config{ + MinVersion: tls.VersionTLS10, + CipherSuites: suiteIds, + } +} diff --git a/internal/pkg/core/notifier/providers/email/email_test.go b/internal/pkg/core/notifier/providers/email/email_test.go new file mode 100644 index 00000000..1197a1a6 --- /dev/null +++ b/internal/pkg/core/notifier/providers/email/email_test.go @@ -0,0 +1,51 @@ +package email_test + +import ( + "os" + "strconv" + "testing" + + notifierEmail "github.com/usual2970/certimate/internal/pkg/core/notifier/providers/email" +) + +/* +Shell command to run this test: + + CERTIMATE_NOTIFIER_EMAIL_SMTPPORT=465 \ + CERTIMATE_NOTIFIER_EMAIL_SMTPTLS=true \ + CERTIMATE_NOTIFIER_EMAIL_SMTPHOST="smtp.example.com" \ + CERTIMATE_NOTIFIER_EMAIL_USERNAME="your-username" \ + CERTIMATE_NOTIFIER_EMAIL_PASSWORD="your-password" \ + CERTIMATE_NOTIFIER_EMAIL_SENDERADDRESS="sender@example.com" \ + CERTIMATE_NOTIFIER_EMAIL_RECEIVERADDRESS="receiver@example.com" \ + go test -v -run TestNotify email_test.go +*/ +func TestNotify(t *testing.T) { + smtpPort, err := strconv.ParseInt(os.Getenv("CERTIMATE_NOTIFIER_EMAIL_SMTPPORT"), 10, 32) + if err != nil { + t.Errorf("invalid envvar: %+v", err) + panic(err) + } + + smtpTLS, err := strconv.ParseBool(os.Getenv("CERTIMATE_NOTIFIER_EMAIL_SMTPTLS")) + if err != nil { + t.Errorf("invalid envvar: %+v", err) + panic(err) + } + + res, err := notifierEmail.New(¬ifierEmail.EmailNotifierConfig{ + SmtpHost: os.Getenv("CERTIMATE_NOTIFIER_EMAIL_SMTPHOST"), + SmtpPort: int32(smtpPort), + SmtpTLS: smtpTLS, + Username: os.Getenv("CERTIMATE_NOTIFIER_EMAIL_USERNAME"), + Password: os.Getenv("CERTIMATE_NOTIFIER_EMAIL_PASSWORD"), + SenderAddress: os.Getenv("CERTIMATE_NOTIFIER_EMAIL_SENDERADDRESS"), + ReceiverAddress: os.Getenv("CERTIMATE_NOTIFIER_EMAIL_RECEIVERADDRESS"), + }) + if err != nil { + t.Errorf("invalid envvar: %+v", err) + panic(err) + } + + t.Logf("notify result: %v", res) +} diff --git a/internal/pkg/core/notifier/providers/lark/lark.go b/internal/pkg/core/notifier/providers/lark/lark.go new file mode 100644 index 00000000..7a1dfc13 --- /dev/null +++ b/internal/pkg/core/notifier/providers/lark/lark.go @@ -0,0 +1,41 @@ +package lark + +import ( + "context" + "errors" + + "github.com/nikoksr/notify/service/lark" + + "github.com/usual2970/certimate/internal/pkg/core/notifier" +) + +type LarkNotifierConfig struct { + WebhookUrl string `json:"webhookUrl"` +} + +type LarkNotifier struct { + config *LarkNotifierConfig +} + +var _ notifier.Notifier = (*LarkNotifier)(nil) + +func New(config *LarkNotifierConfig) (*LarkNotifier, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + return &LarkNotifier{ + config: config, + }, nil +} + +func (n *LarkNotifier) Notify(ctx context.Context, subject string, message string) (res *notifier.NotifyResult, err error) { + srv := lark.NewWebhookService(n.config.WebhookUrl) + + err = srv.Send(ctx, subject, message) + if err != nil { + return nil, err + } + + return ¬ifier.NotifyResult{}, nil +} diff --git a/internal/pkg/core/notifier/providers/serverchan/serverchan.go b/internal/pkg/core/notifier/providers/serverchan/serverchan.go new file mode 100644 index 00000000..07d2b6e0 --- /dev/null +++ b/internal/pkg/core/notifier/providers/serverchan/serverchan.go @@ -0,0 +1,55 @@ +package serverchan + +import ( + "context" + "errors" + "net/http" + + notifyHttp "github.com/nikoksr/notify/service/http" + + "github.com/usual2970/certimate/internal/pkg/core/notifier" +) + +type ServerChanNotifierConfig struct { + Url string `json:"url"` +} + +type ServerChanNotifier struct { + config *ServerChanNotifierConfig +} + +var _ notifier.Notifier = (*ServerChanNotifier)(nil) + +func New(config *ServerChanNotifierConfig) (*ServerChanNotifier, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + return &ServerChanNotifier{ + config: config, + }, nil +} + +func (n *ServerChanNotifier) Notify(ctx context.Context, subject string, message string) (res *notifier.NotifyResult, err error) { + srv := notifyHttp.New() + + srv.AddReceivers(¬ifyHttp.Webhook{ + URL: n.config.Url, + Header: http.Header{}, + ContentType: "application/json", + Method: http.MethodPost, + BuildPayload: func(subject, message string) (payload any) { + return map[string]string{ + "text": subject, + "desp": message, + } + }, + }) + + err = srv.Send(ctx, subject, message) + if err != nil { + return nil, err + } + + return ¬ifier.NotifyResult{}, nil +} diff --git a/internal/pkg/core/notifier/providers/telegram/telegram.go b/internal/pkg/core/notifier/providers/telegram/telegram.go new file mode 100644 index 00000000..3560b87b --- /dev/null +++ b/internal/pkg/core/notifier/providers/telegram/telegram.go @@ -0,0 +1,47 @@ +package telegram + +import ( + "context" + "errors" + + "github.com/nikoksr/notify/service/telegram" + + "github.com/usual2970/certimate/internal/pkg/core/notifier" +) + +type TelegramNotifierConfig struct { + ApiToken string `json:"apiToken"` + ChatId int64 `json:"chatId"` +} + +type TelegramNotifier struct { + config *TelegramNotifierConfig +} + +var _ notifier.Notifier = (*TelegramNotifier)(nil) + +func New(config *TelegramNotifierConfig) (*TelegramNotifier, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + return &TelegramNotifier{ + config: config, + }, nil +} + +func (n *TelegramNotifier) Notify(ctx context.Context, subject string, message string) (res *notifier.NotifyResult, err error) { + srv, err := telegram.New(n.config.ApiToken) + if err != nil { + return nil, err + } + + srv.AddReceivers(n.config.ChatId) + + err = srv.Send(ctx, subject, message) + if err != nil { + return nil, err + } + + return ¬ifier.NotifyResult{}, nil +} diff --git a/internal/pkg/core/notifier/providers/webhook/webhook.go b/internal/pkg/core/notifier/providers/webhook/webhook.go new file mode 100644 index 00000000..aa27014a --- /dev/null +++ b/internal/pkg/core/notifier/providers/webhook/webhook.go @@ -0,0 +1,43 @@ +package webhook + +import ( + "context" + "errors" + + "github.com/nikoksr/notify/service/http" + + "github.com/usual2970/certimate/internal/pkg/core/notifier" +) + +type WebhookNotifierConfig struct { + Url string `json:"url"` +} + +type WebhookNotifier struct { + config *WebhookNotifierConfig +} + +var _ notifier.Notifier = (*WebhookNotifier)(nil) + +func New(config *WebhookNotifierConfig) (*WebhookNotifier, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + return &WebhookNotifier{ + config: config, + }, nil +} + +func (n *WebhookNotifier) Notify(ctx context.Context, subject string, message string) (res *notifier.NotifyResult, err error) { + srv := http.New() + + srv.AddReceiversURLs(n.config.Url) + + err = srv.Send(ctx, subject, message) + if err != nil { + return nil, err + } + + return ¬ifier.NotifyResult{}, nil +} diff --git a/internal/pkg/core/uploader/providers/aliyun-cas/aliyun_cas.go b/internal/pkg/core/uploader/providers/aliyun-cas/aliyun_cas.go index c524f115..463d10bd 100644 --- a/internal/pkg/core/uploader/providers/aliyun-cas/aliyun_cas.go +++ b/internal/pkg/core/uploader/providers/aliyun-cas/aliyun_cas.go @@ -2,6 +2,7 @@ import ( "context" + "errors" "fmt" "strings" "time" @@ -26,7 +27,13 @@ type AliyunCASUploader struct { sdkClient *aliyunCas.Client } +var _ uploader.Uploader = (*AliyunCASUploader)(nil) + func New(config *AliyunCASUploaderConfig) (*AliyunCASUploader, error) { + if config == nil { + return nil, errors.New("config is nil") + } + client, err := createSdkClient( config.AccessKeyId, config.AccessKeySecret, diff --git a/internal/pkg/core/uploader/providers/aliyun-slb/aliyun_slb.go b/internal/pkg/core/uploader/providers/aliyun-slb/aliyun_slb.go index 9a396c89..aebf674c 100644 --- a/internal/pkg/core/uploader/providers/aliyun-slb/aliyun_slb.go +++ b/internal/pkg/core/uploader/providers/aliyun-slb/aliyun_slb.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "strings" "time" @@ -28,7 +29,13 @@ type AliyunSLBUploader struct { sdkClient *aliyunSlb.Client } +var _ uploader.Uploader = (*AliyunSLBUploader)(nil) + func New(config *AliyunSLBUploaderConfig) (*AliyunSLBUploader, error) { + if config == nil { + return nil, errors.New("config is nil") + } + client, err := createSdkClient( config.AccessKeyId, config.AccessKeySecret, diff --git a/internal/pkg/core/uploader/providers/dogecloud/dogecloud.go b/internal/pkg/core/uploader/providers/dogecloud/dogecloud.go index 0dd94391..1daec4bb 100644 --- a/internal/pkg/core/uploader/providers/dogecloud/dogecloud.go +++ b/internal/pkg/core/uploader/providers/dogecloud/dogecloud.go @@ -2,6 +2,7 @@ import ( "context" + "errors" "fmt" "time" @@ -21,7 +22,13 @@ type DogeCloudUploader struct { sdkClient *doge.Client } +var _ uploader.Uploader = (*DogeCloudUploader)(nil) + func New(config *DogeCloudUploaderConfig) (*DogeCloudUploader, error) { + if config == nil { + return nil, errors.New("config is nil") + } + client, err := createSdkClient( config.AccessKey, config.SecretKey, diff --git a/internal/pkg/core/uploader/providers/huaweicloud-elb/huaweicloud_elb.go b/internal/pkg/core/uploader/providers/huaweicloud-elb/huaweicloud_elb.go index 198494b3..5b6ab376 100644 --- a/internal/pkg/core/uploader/providers/huaweicloud-elb/huaweicloud_elb.go +++ b/internal/pkg/core/uploader/providers/huaweicloud-elb/huaweicloud_elb.go @@ -32,7 +32,13 @@ type HuaweiCloudELBUploader struct { sdkClient *hcElb.ElbClient } +var _ uploader.Uploader = (*HuaweiCloudELBUploader)(nil) + func New(config *HuaweiCloudELBUploaderConfig) (*HuaweiCloudELBUploader, error) { + if config == nil { + return nil, errors.New("config is nil") + } + client, err := createSdkClient( config.AccessKeyId, config.SecretAccessKey, diff --git a/internal/pkg/core/uploader/providers/huaweicloud-scm/huaweicloud_scm.go b/internal/pkg/core/uploader/providers/huaweicloud-scm/huaweicloud_scm.go index b8510900..45450d9e 100644 --- a/internal/pkg/core/uploader/providers/huaweicloud-scm/huaweicloud_scm.go +++ b/internal/pkg/core/uploader/providers/huaweicloud-scm/huaweicloud_scm.go @@ -2,6 +2,7 @@ import ( "context" + "errors" "fmt" "time" @@ -27,7 +28,13 @@ type HuaweiCloudSCMUploader struct { sdkClient *hcScm.ScmClient } +var _ uploader.Uploader = (*HuaweiCloudSCMUploader)(nil) + func New(config *HuaweiCloudSCMUploaderConfig) (*HuaweiCloudSCMUploader, error) { + if config == nil { + return nil, errors.New("config is nil") + } + client, err := createSdkClient( config.AccessKeyId, config.SecretAccessKey, diff --git a/internal/pkg/core/uploader/providers/qiniu-sslcert/qiniu_sslcert.go b/internal/pkg/core/uploader/providers/qiniu-sslcert/qiniu_sslcert.go index 010c55d1..afd36316 100644 --- a/internal/pkg/core/uploader/providers/qiniu-sslcert/qiniu_sslcert.go +++ b/internal/pkg/core/uploader/providers/qiniu-sslcert/qiniu_sslcert.go @@ -2,6 +2,7 @@ import ( "context" + "errors" "fmt" "time" @@ -23,7 +24,13 @@ type QiniuSSLCertUploader struct { sdkClient *qiniuEx.Client } +var _ uploader.Uploader = (*QiniuSSLCertUploader)(nil) + func New(config *QiniuSSLCertUploaderConfig) (*QiniuSSLCertUploader, error) { + if config == nil { + return nil, errors.New("config is nil") + } + client, err := createSdkClient( config.AccessKey, config.SecretKey, diff --git a/internal/pkg/core/uploader/providers/tencentcloud-ssl/tencentcloud_ssl.go b/internal/pkg/core/uploader/providers/tencentcloud-ssl/tencentcloud_ssl.go index c6e5dcb5..f0755f3e 100644 --- a/internal/pkg/core/uploader/providers/tencentcloud-ssl/tencentcloud_ssl.go +++ b/internal/pkg/core/uploader/providers/tencentcloud-ssl/tencentcloud_ssl.go @@ -2,6 +2,7 @@ import ( "context" + "errors" xerrors "github.com/pkg/errors" "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" @@ -21,7 +22,13 @@ type TencentCloudSSLUploader struct { sdkClient *tcSsl.Client } +var _ uploader.Uploader = (*TencentCloudSSLUploader)(nil) + func New(config *TencentCloudSSLUploaderConfig) (*TencentCloudSSLUploader, error) { + if config == nil { + return nil, errors.New("config is nil") + } + client, err := createSdkClient( config.SecretId, config.SecretKey, diff --git a/internal/pkg/core/uploader/uploader.go b/internal/pkg/core/uploader/uploader.go index 87a4d633..1fc34d82 100644 --- a/internal/pkg/core/uploader/uploader.go +++ b/internal/pkg/core/uploader/uploader.go @@ -2,7 +2,7 @@ import "context" -// 表示定义证书上传者的抽象类型接口。 +// 表示定义证书上传器的抽象类型接口。 // 云服务商通常会提供 SSL 证书管理服务,可供用户集中管理证书。 // 注意与 `Deployer` 区分,“上传”通常为“部署”的前置操作。 type Uploader interface { diff --git a/internal/pkg/utils/maps/maps.go b/internal/pkg/utils/maps/maps.go new file mode 100644 index 00000000..6f3c6fe6 --- /dev/null +++ b/internal/pkg/utils/maps/maps.go @@ -0,0 +1,164 @@ +package maps + +import "strconv" + +// 以字符串形式从字典中获取指定键的值。 +// +// 入参: +// - dict: 字典。 +// - key: 键。 +// +// 出参: +// - 字典中键对应的值。如果指定键不存在或者值的类型不是字符串,则返回空字符串。 +func GetValueAsString(dict map[string]any, key string) string { + return GetValueOrDefaultAsString(dict, key, "") +} + +// 以字符串形式从字典中获取指定键的值。 +// +// 入参: +// - dict: 字典。 +// - key: 键。 +// - defaultValue: 默认值。 +// +// 出参: +// - 字典中键对应的值。如果指定键不存在或者值的类型不是字符串,则返回默认值。 +func GetValueOrDefaultAsString(dict map[string]any, key string, defaultValue string) string { + if dict == nil { + return defaultValue + } + + if value, ok := dict[key]; ok { + if result, ok := value.(string); ok { + return result + } + } + + return defaultValue +} + +// 以 32 位整数形式从字典中获取指定键的值。 +// +// 入参: +// - dict: 字典。 +// - key: 键。 +// +// 出参: +// - 字典中键对应的值。如果指定键不存在或者值的类型不是 32 位整数,则返回 0。 +func GetValueAsInt32(dict map[string]any, key string) int32 { + return GetValueOrDefaultAsInt32(dict, key, 0) +} + +// 以 32 位整数形式从字典中获取指定键的值。 +// +// 入参: +// - dict: 字典。 +// - key: 键。 +// - defaultValue: 默认值。 +// +// 出参: +// - 字典中键对应的值。如果指定键不存在或者值的类型不是 32 位整数,则返回默认值。 +func GetValueOrDefaultAsInt32(dict map[string]any, key string, defaultValue int32) int32 { + if dict == nil { + return defaultValue + } + + if value, ok := dict[key]; ok { + if result, ok := value.(int32); ok { + return result + } + + // 兼容字符串类型的值 + if str, ok := value.(string); ok { + if result, err := strconv.ParseInt(str, 10, 32); err == nil { + return int32(result) + } + } + } + + return defaultValue +} + +// 以 64 位整数形式从字典中获取指定键的值。 +// +// 入参: +// - dict: 字典。 +// - key: 键。 +// +// 出参: +// - 字典中键对应的值。如果指定键不存在或者值的类型不是 64 位整数,则返回 0。 +func GetValueAsInt64(dict map[string]any, key string) int64 { + return GetValueOrDefaultAsInt64(dict, key, 0) +} + +// 以 64 位整数形式从字典中获取指定键的值。 +// +// 入参: +// - dict: 字典。 +// - key: 键。 +// - defaultValue: 默认值。 +// +// 出参: +// - 字典中键对应的值。如果指定键不存在或者值的类型不是 64 位整数,则返回默认值。 +func GetValueOrDefaultAsInt64(dict map[string]any, key string, defaultValue int64) int64 { + if dict == nil { + return defaultValue + } + + if value, ok := dict[key]; ok { + if result, ok := value.(int64); ok { + return result + } + + // 兼容字符串类型的值 + if str, ok := value.(string); ok { + if result, err := strconv.ParseInt(str, 10, 64); err == nil { + return result + } + } + } + + return defaultValue +} + +// 以布尔形式从字典中获取指定键的值。 +// +// 入参: +// - dict: 字典。 +// - key: 键。 +// +// 出参: +// - 字典中键对应的值。如果指定键不存在或者值的类型不是布尔,则返回 false。 +func GetValueAsBool(dict map[string]any, key string) bool { + return GetValueOrDefaultAsBool(dict, key, false) +} + +// 以布尔形式从字典中获取指定键的值。 +// +// 入参: +// - dict: 字典。 +// - key: 键。 +// - defaultValue: 默认值。 +// +// 出参: +// - 字典中键对应的值。如果指定键不存在或者值的类型不是布尔,则返回默认值。 +func GetValueOrDefaultAsBool(dict map[string]any, key string, defaultValue bool) bool { + if dict == nil { + return defaultValue + } + + if value, ok := dict[key]; ok { + if result, ok := value.(bool); ok { + return result + } + + // 兼容字符串类型的值 + if str, ok := value.(string); ok { + if result, err := strconv.ParseBool(str); err == nil { + return result + } + } + } + + return defaultValue +} diff --git a/ui/src/api/notify.ts b/ui/src/api/notify.ts index 2c9c3b9d..52400b32 100644 --- a/ui/src/api/notify.ts +++ b/ui/src/api/notify.ts @@ -19,4 +19,3 @@ export const notifyTest = async (channel: string) => { return resp; }; - diff --git a/ui/src/components/notify/Bark.tsx b/ui/src/components/notify/Bark.tsx index 8eaf8042..0b651cb6 100644 --- a/ui/src/components/notify/Bark.tsx +++ b/ui/src/components/notify/Bark.tsx @@ -123,22 +123,29 @@ const Bark = () => { } }; + const [testing, setTesting] = useState(false); const handlePushTestClick = async () => { + if (testing) return; + try { + setTesting(true); + await notifyTest("bark"); toast({ - title: t("settings.notification.config.push.test.message.success.message"), - description: t("settings.notification.config.push.test.message.success.message"), + title: t("settings.notification.push_test_message.succeeded.message"), + description: t("settings.notification.push_test_message.succeeded.message"), }); } catch (e) { const msg = getErrMessage(e); toast({ - title: t("settings.notification.config.push.test.message.failed.message"), - description: `${t("settings.notification.config.push.test.message.failed.message")}: ${msg}`, + title: t("settings.notification.push_test_message.failed.message"), + description: `${t("settings.notification.push_test_message.failed.message")}: ${msg}`, variant: "destructive", }); + } finally { + setTesting(false); } }; @@ -177,67 +184,76 @@ const Bark = () => { }; return ( -
- { - const newData = { - ...bark, - data: { - ...bark.data, - serverUrl: e.target.value, - }, - }; +
+
+ + { + const newData = { + ...bark, + data: { + ...bark.data, + serverUrl: e.target.value, + }, + }; - checkChanged(newData.data); - setBark(newData); - }} - /> - - { - const newData = { - ...bark, - data: { - ...bark.data, - deviceKey: e.target.value, - }, - }; - - checkChanged(newData.data); - setBark(newData); - }} - /> - -
- - + checkChanged(newData.data); + setBark(newData); + }} + />
-
- - - +
+ + { + const newData = { + ...bark, + data: { + ...bark.data, + deviceKey: e.target.value, + }, + }; - - - + checkChanged(newData.data); + setBark(newData); + }} + /> +
+ +
+
+ + +
+ +
+ + + + + + + +
); diff --git a/ui/src/components/notify/DingTalk.tsx b/ui/src/components/notify/DingTalk.tsx index c3841144..ab9ff9d1 100644 --- a/ui/src/components/notify/DingTalk.tsx +++ b/ui/src/components/notify/DingTalk.tsx @@ -120,23 +120,30 @@ const DingTalk = () => { description: `${t("settings.notification.config.failed.message")}: ${msg}`, variant: "destructive", }); + } finally { + setTesting(false); } }; + const [testing, setTesting] = useState(false); const handlePushTestClick = async () => { + if (testing) return; + try { + setTesting(true); + await notifyTest("dingtalk"); toast({ - title: t("settings.notification.config.push.test.message.success.message"), - description: t("settings.notification.config.push.test.message.success.message"), + title: t("settings.notification.push_test_message.succeeded.message"), + description: t("settings.notification.push_test_message.succeeded.message"), }); } catch (e) { const msg = getErrMessage(e); toast({ - title: t("settings.notification.config.push.test.message.failed.message"), - description: `${t("settings.notification.config.push.test.message.failed.message")}: ${msg}`, + title: t("settings.notification.push_test_message.failed.message"), + description: `${t("settings.notification.push_test_message.failed.message")}: ${msg}`, variant: "destructive", }); } @@ -177,64 +184,74 @@ const DingTalk = () => { }; return ( -
- { - const newData = { - ...dingtalk, - data: { - ...dingtalk.data, - accessToken: e.target.value, - }, - }; - checkChanged(newData.data); - setDingtalk(newData); - }} - /> - { - const newData = { - ...dingtalk, - data: { - ...dingtalk.data, - secret: e.target.value, - }, - }; - checkChanged(newData.data); - setDingtalk(newData); - }} - /> -
- - +
+
+ + { + const newData = { + ...dingtalk, + data: { + ...dingtalk.data, + accessToken: e.target.value, + }, + }; + checkChanged(newData.data); + setDingtalk(newData); + }} + />
-
- - - +
+ + { + const newData = { + ...dingtalk, + data: { + ...dingtalk.data, + secret: e.target.value, + }, + }; + checkChanged(newData.data); + setDingtalk(newData); + }} + /> +
- - - +
+
+ + +
+ +
+ + + + + + + +
); diff --git a/ui/src/components/notify/Email.tsx b/ui/src/components/notify/Email.tsx new file mode 100644 index 00000000..f778e73c --- /dev/null +++ b/ui/src/components/notify/Email.tsx @@ -0,0 +1,384 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { useToast } from "@/components/ui/use-toast"; +import { getErrMessage } from "@/lib/error"; +import { NotifyChannelEmail, NotifyChannels } from "@/domain/settings"; +import { useNotifyContext } from "@/providers/notify"; +import { update } from "@/repository/settings"; +import Show from "@/components/Show"; +import { notifyTest } from "@/api/notify"; + +type EmailSetting = { + id: string; + name: string; + data: NotifyChannelEmail; +}; + +const Mail = () => { + const { config, setChannels } = useNotifyContext(); + const { t } = useTranslation(); + + const [changed, setChanged] = useState(false); + + const [mail, setMail] = useState({ + id: config.id ?? "", + name: "notifyChannels", + data: { + smtpHost: "", + smtpPort: 465, + smtpTLS: true, + username: "", + password: "", + senderAddress: "", + receiverAddress: "", + enabled: false, + }, + }); + + const [originMail, setOriginMail] = useState({ + id: config.id ?? "", + name: "notifyChannels", + data: { + smtpHost: "", + smtpPort: 465, + smtpTLS: true, + username: "", + password: "", + senderAddress: "", + receiverAddress: "", + enabled: false, + }, + }); + + useEffect(() => { + setChanged(false); + }, [config]); + + useEffect(() => { + const data = getDetailMail(); + setOriginMail({ + id: config.id ?? "", + name: "email", + data, + }); + }, [config]); + + useEffect(() => { + const data = getDetailMail(); + setMail({ + id: config.id ?? "", + name: "email", + data, + }); + }, [config]); + + const { toast } = useToast(); + + const getDetailMail = () => { + const df: NotifyChannelEmail = { + smtpHost: "smtp.example.com", + smtpPort: 465, + smtpTLS: true, + username: "", + password: "", + senderAddress: "", + receiverAddress: "", + enabled: false, + }; + if (!config.content) { + return df; + } + const chanels = config.content as NotifyChannels; + if (!chanels.email) { + return df; + } + + return chanels.email as NotifyChannelEmail; + }; + + const checkChanged = (data: NotifyChannelEmail) => { + if ( + data.smtpHost !== originMail.data.smtpHost || + data.smtpPort !== originMail.data.smtpPort || + data.smtpTLS !== originMail.data.smtpTLS || + data.username !== originMail.data.username || + data.password !== originMail.data.password || + data.senderAddress !== originMail.data.senderAddress || + data.receiverAddress !== originMail.data.receiverAddress + ) { + setChanged(true); + } else { + setChanged(false); + } + }; + + const handleSaveClick = async () => { + try { + const resp = await update({ + ...config, + name: "notifyChannels", + content: { + ...config.content, + email: { + ...mail.data, + }, + }, + }); + + setChannels(resp); + toast({ + title: t("common.save.succeeded.message"), + description: t("settings.notification.config.saved.message"), + }); + } catch (e) { + const msg = getErrMessage(e); + + toast({ + title: t("common.save.failed.message"), + description: `${t("settings.notification.config.failed.message")}: ${msg}`, + variant: "destructive", + }); + } + }; + + const [testing, setTesting] = useState(false); + const handlePushTestClick = async () => { + if (testing) return; + + try { + setTesting(true); + + await notifyTest("email"); + + toast({ + title: t("settings.notification.push_test_message.succeeded.message"), + description: t("settings.notification.push_test_message.succeeded.message"), + }); + } catch (e) { + const msg = getErrMessage(e); + + toast({ + title: t("settings.notification.push_test_message.failed.message"), + description: `${t("settings.notification.push_test_message.failed.message")}: ${msg}`, + variant: "destructive", + }); + } finally { + setTesting(false); + } + }; + + const handleSwitchChange = async () => { + const newData = { + ...mail, + data: { + ...mail.data, + enabled: !mail.data.enabled, + }, + }; + setMail(newData); + + try { + const resp = await update({ + ...config, + name: "notifyChannels", + content: { + ...config.content, + email: { + ...newData.data, + }, + }, + }); + + setChannels(resp); + } catch (e) { + const msg = getErrMessage(e); + + toast({ + title: t("common.save.failed.message"), + description: `${t("settings.notification.config.failed.message")}: ${msg}`, + variant: "destructive", + }); + } + }; + + return ( +
+
+
+ + { + const newData = { + ...mail, + data: { + ...mail.data, + smtpHost: e.target.value, + }, + }; + checkChanged(newData.data); + setMail(newData); + }} + /> +
+ +
+ + { + const newData = { + ...mail, + data: { + ...mail.data, + smtpPort: +e.target.value || 0, + }, + }; + checkChanged(newData.data); + setMail(newData); + }} + /> +
+ +
+ + { + const newData = { + ...mail, + data: { + ...mail.data, + smtpPort: e && mail.data.smtpPort === 25 ? 465 : !e && mail.data.smtpPort === 465 ? 25 : mail.data.smtpPort, + smtpTLS: e, + }, + }; + checkChanged(newData.data); + setMail(newData); + }} + /> +
+
+ +
+
+ + { + const newData = { + ...mail, + data: { + ...mail.data, + username: e.target.value, + }, + }; + checkChanged(newData.data); + setMail(newData); + }} + /> +
+ +
+ + { + const newData = { + ...mail, + data: { + ...mail.data, + password: e.target.value, + }, + }; + checkChanged(newData.data); + setMail(newData); + }} + /> +
+
+ +
+ + { + const newData = { + ...mail, + data: { + ...mail.data, + senderAddress: e.target.value, + }, + }; + checkChanged(newData.data); + setMail(newData); + }} + /> +
+ +
+ + { + const newData = { + ...mail, + data: { + ...mail.data, + receiverAddress: e.target.value, + }, + }; + checkChanged(newData.data); + setMail(newData); + }} + /> +
+ +
+
+ + +
+ +
+ + + + + + + +
+
+
+ ); +}; + +export default Mail; diff --git a/ui/src/components/notify/Lark.tsx b/ui/src/components/notify/Lark.tsx index f8bf7534..89a6eadd 100644 --- a/ui/src/components/notify/Lark.tsx +++ b/ui/src/components/notify/Lark.tsx @@ -116,23 +116,30 @@ const Lark = () => { description: `${t("settings.notification.config.failed.message")}: ${msg}`, variant: "destructive", }); + } finally { + setTesting(false); } }; + const [testing, setTesting] = useState(false); const handlePushTestClick = async () => { + if (testing) return; + try { + setTesting(true); + await notifyTest("lark"); toast({ - title: t("settings.notification.config.push.test.message.success.message"), - description: t("settings.notification.config.push.test.message.success.message"), + title: t("settings.notification.push_test_message.succeeded.message"), + description: t("settings.notification.push_test_message.succeeded.message"), }); } catch (e) { const msg = getErrMessage(e); toast({ - title: t("settings.notification.config.push.test.message.failed.message"), - description: `${t("settings.notification.config.push.test.message.failed.message")}: ${msg}`, + title: t("settings.notification.push_test_message.failed.message"), + description: `${t("settings.notification.push_test_message.failed.message")}: ${msg}`, variant: "destructive", }); } @@ -173,49 +180,56 @@ const Lark = () => { }; return ( -
- { - const newData = { - ...lark, - data: { - ...lark.data, - webhookUrl: e.target.value, - }, - }; +
+
+ + { + const newData = { + ...lark, + data: { + ...lark.data, + webhookUrl: e.target.value, + }, + }; - checkChanged(newData.data); - setLark(newData); - }} - /> -
- - + checkChanged(newData.data); + setLark(newData); + }} + />
-
- - - +
+
+ + +
- - - +
+ + + + + + + +
); diff --git a/ui/src/components/notify/ServerChan.tsx b/ui/src/components/notify/ServerChan.tsx index f31c7bdc..ee9b23eb 100644 --- a/ui/src/components/notify/ServerChan.tsx +++ b/ui/src/components/notify/ServerChan.tsx @@ -97,7 +97,7 @@ const ServerChan = () => { if (!isValidURL(serverchan.data.url)) { toast({ title: t("common.save.failed.message"), - description: t("settings.notification.url.errmsg.invalid"), + description: t("common.errmsg.url_invalid"), variant: "destructive", }); return; @@ -130,22 +130,29 @@ const ServerChan = () => { } }; + const [testing, setTesting] = useState(false); const handlePushTestClick = async () => { + if (testing) return; + try { + setTesting(true); + await notifyTest("serverchan"); toast({ - title: t("settings.notification.config.push.test.message.success.message"), - description: t("settings.notification.config.push.test.message.success.message"), + title: t("settings.notification.push_test_message.succeeded.message"), + description: t("settings.notification.push_test_message.succeeded.message"), }); } catch (e) { const msg = getErrMessage(e); toast({ - title: t("settings.notification.config.push.test.message.failed.message"), - description: `${t("settings.notification.config.push.test.message.failed.message")}: ${msg}`, + title: t("settings.notification.push_test_message.failed.message"), + description: `${t("settings.notification.push_test_message.failed.message")}: ${msg}`, variant: "destructive", }); + } finally { + setTesting(false); } }; @@ -184,50 +191,56 @@ const ServerChan = () => { }; return ( -
- { - const newData = { - ...serverchan, - data: { - ...serverchan.data, - url: e.target.value, - }, - }; +
+
+ + { + const newData = { + ...serverchan, + data: { + ...serverchan.data, + url: e.target.value, + }, + }; - checkChanged(newData.data); - setServerChan(newData); - }} - /> - -
- - + checkChanged(newData.data); + setServerChan(newData); + }} + />
-
- - - +
+
+ + +
- - - +
+ + + + + + + +
); diff --git a/ui/src/components/notify/Telegram.tsx b/ui/src/components/notify/Telegram.tsx index 419126fc..63f6079c 100644 --- a/ui/src/components/notify/Telegram.tsx +++ b/ui/src/components/notify/Telegram.tsx @@ -123,22 +123,29 @@ const Telegram = () => { } }; + const [testing, setTesting] = useState(false); const handlePushTestClick = async () => { + if (testing) return; + try { + setTesting(true); + await notifyTest("telegram"); toast({ - title: t("settings.notification.config.push.test.message.success.message"), - description: t("settings.notification.config.push.test.message.success.message"), + title: t("settings.notification.push_test_message.succeeded.message"), + description: t("settings.notification.push_test_message.succeeded.message"), }); } catch (e) { const msg = getErrMessage(e); toast({ - title: t("settings.notification.config.push.test.message.failed.message"), - description: `${t("settings.notification.config.push.test.message.failed.message")}: ${msg}`, + title: t("settings.notification.push_test_message.failed.message"), + description: `${t("settings.notification.push_test_message.failed.message")}: ${msg}`, variant: "destructive", }); + } finally { + setTesting(false); } }; @@ -177,67 +184,76 @@ const Telegram = () => { }; return ( -
- { - const newData = { - ...telegram, - data: { - ...telegram.data, - apiToken: e.target.value, - }, - }; +
+
+ + { + const newData = { + ...telegram, + data: { + ...telegram.data, + apiToken: e.target.value, + }, + }; - checkChanged(newData.data); - setTelegram(newData); - }} - /> - - { - const newData = { - ...telegram, - data: { - ...telegram.data, - chatId: e.target.value, - }, - }; - - checkChanged(newData.data); - setTelegram(newData); - }} - /> - -
- - + checkChanged(newData.data); + setTelegram(newData); + }} + />
-
- - - +
+ + { + const newData = { + ...telegram, + data: { + ...telegram.data, + chatId: e.target.value, + }, + }; - - - + checkChanged(newData.data); + setTelegram(newData); + }} + /> +
+ +
+
+ + +
+ +
+ + + + + + + +
); diff --git a/ui/src/components/notify/Webhook.tsx b/ui/src/components/notify/Webhook.tsx index b614b50c..70c8d052 100644 --- a/ui/src/components/notify/Webhook.tsx +++ b/ui/src/components/notify/Webhook.tsx @@ -97,7 +97,7 @@ const Webhook = () => { if (!isValidURL(webhook.data.url)) { toast({ title: t("common.save.failed.message"), - description: t("settings.notification.url.errmsg.invalid"), + description: t("common.errmsg.url_invalid"), variant: "destructive", }); return; @@ -130,22 +130,29 @@ const Webhook = () => { } }; + const [testing, setTesting] = useState(false); const handlePushTestClick = async () => { + if (testing) return; + try { + setTesting(true); + await notifyTest("webhook"); toast({ - title: t("settings.notification.config.push.test.message.success.message"), - description: t("settings.notification.config.push.test.message.success.message"), + title: t("settings.notification.push_test_message.succeeded.message"), + description: t("settings.notification.push_test_message.succeeded.message"), }); } catch (e) { const msg = getErrMessage(e); toast({ - title: t("settings.notification.config.push.test.message.failed.message"), - description: `${t("settings.notification.config.push.test.message.failed.message")}: ${msg}`, + title: t("settings.notification.push_test_message.failed.message"), + description: `${t("settings.notification.push_test_message.failed.message")}: ${msg}`, variant: "destructive", }); + } finally { + setTesting(false); } }; @@ -184,50 +191,56 @@ const Webhook = () => { }; return ( -
- { - const newData = { - ...webhook, - data: { - ...webhook.data, - url: e.target.value, - }, - }; +
+
+ + { + const newData = { + ...webhook, + data: { + ...webhook.data, + url: e.target.value, + }, + }; - checkChanged(newData.data); - setWebhook(newData); - }} - /> - -
- - + checkChanged(newData.data); + setWebhook(newData); + }} + />
-
- - - +
+
+ + +
- - - +
+ + + + + + + +
); diff --git a/ui/src/components/ui/button.tsx b/ui/src/components/ui/button.tsx index 3c999f71..0b9957db 100644 --- a/ui/src/components/ui/button.tsx +++ b/ui/src/components/ui/button.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; +import { Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -32,11 +33,38 @@ const buttonVariants = cva( export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean; + loading?: boolean; } -const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button"; - return ; +const Button = React.forwardRef(({ className, variant, size, loading, asChild = false, children, ...props }, ref) => { + if (asChild) { + return ( + + <> + {React.Children.map(children as React.ReactElement, (child: React.ReactElement) => { + return React.cloneElement(child, { + className: cn(buttonVariants({ variant, size }), className), + children: ( + <> + {loading && } + {child.props.children} + + ), + }); + })} + + + ); + } + + return ( + + ); }); Button.displayName = "Button"; diff --git a/ui/src/components/workflow/BranchNode.tsx b/ui/src/components/workflow/BranchNode.tsx index b513c112..f062b33e 100644 --- a/ui/src/components/workflow/BranchNode.tsx +++ b/ui/src/components/workflow/BranchNode.tsx @@ -35,7 +35,7 @@ const BranchNode = memo(({ data }: BrandNodeProps) => { }} size={"sm"} variant={"outline"} - className="text-xs px-2 h-6 rounded-full absolute -top-3 left-[50%] -translate-x-1/2 z-10" + className="text-xs px-2 h-6 rounded-full absolute -top-3 left-[50%] -translate-x-1/2 z-10 dark:text-stone-200" > {t("workflow.node.addBranch.label")} diff --git a/ui/src/components/workflow/ConditionNode.tsx b/ui/src/components/workflow/ConditionNode.tsx index f4823b33..e4f31f34 100644 --- a/ui/src/components/workflow/ConditionNode.tsx +++ b/ui/src/components/workflow/ConditionNode.tsx @@ -34,7 +34,7 @@ const ConditionNode = ({ data, branchId, branchIndex }: NodeProps) => {
-
+
{data.name}
diff --git a/ui/src/components/workflow/DeployForm.tsx b/ui/src/components/workflow/DeployForm.tsx index 64ea31f2..8c608edc 100644 --- a/ui/src/components/workflow/DeployForm.tsx +++ b/ui/src/components/workflow/DeployForm.tsx @@ -24,7 +24,7 @@ export type DeployFormProps = { defaultProivder?: string; }; const DeployForm = ({ data, defaultProivder }: DeployFormProps) => { - return getForm(data, defaultProivder); + return
{getForm(data, defaultProivder)}
; }; export default memo(DeployForm); diff --git a/ui/src/components/workflow/Node.tsx b/ui/src/components/workflow/Node.tsx index f369d186..df1e6e76 100644 --- a/ui/src/components/workflow/Node.tsx +++ b/ui/src/components/workflow/Node.tsx @@ -39,7 +39,6 @@ const Node = ({ data }: NodeProps) => { }; const getSetting = () => { - console.log(data); if (!data.validated) { return <>{t(`${i18nPrefix}.setting.label`)}; } diff --git a/ui/src/components/workflow/NotifyForm.tsx b/ui/src/components/workflow/NotifyForm.tsx index d5047a97..3e07a90c 100644 --- a/ui/src/components/workflow/NotifyForm.tsx +++ b/ui/src/components/workflow/NotifyForm.tsx @@ -93,7 +93,7 @@ const NotifyForm = ({ data }: NotifyFormProps) => { e.stopPropagation(); form.handleSubmit(onSubmit)(e); }} - className="space-y-8" + className="space-y-8 dark:text-stone-200" > = new Map(channels.map((item) => [item.name, item])); +export type NotifyChannelEmail = { + smtpHost: string; + smtpPort: number; + smtpTLS: boolean; + username: string; + password: string; + senderAddress: string; + receiverAddress: string; + enabled: boolean; +}; + +export type NotifyChannelWebhook = { + url: string; + enabled: boolean; +}; export type NotifyChannelDingTalk = { accessToken: string; @@ -84,26 +99,11 @@ export type NotifyChannelTelegram = { enabled: boolean; }; -export type NotifyChannelWebhook = { - url: string; - enabled: boolean; -}; - export type NotifyChannelServerChan = { url: string; enabled: boolean; }; -export type NotifyChannelMail = { - senderAddress: string; - receiverAddresses: string; - smtpHostAddr: string; - smtpHostPort: string; - username: string; - password: string; - enabled: boolean; -}; - export type NotifyChannelBark = { deviceKey: string; serverUrl: string; diff --git a/ui/src/domain/workflow.ts b/ui/src/domain/workflow.ts index 09ac9c76..0e7729c7 100644 --- a/ui/src/domain/workflow.ts +++ b/ui/src/domain/workflow.ts @@ -3,6 +3,20 @@ import { nanoid } from "nanoid"; import i18n from "@/i18n"; import { deployTargets, KVType } from "./domain"; +export type Workflow = { + id?: string; + name: string; + description?: string; + type: string; + crontab?: string; + content?: WorkflowNode; + draft?: WorkflowNode; + enabled?: boolean; + hasDraft?: boolean; + created?: string; + updated?: string; +}; + export enum WorkflowNodeType { Start = "start", End = "end", @@ -94,6 +108,31 @@ type NewWorkflowNodeOptions = { providerType?: string; }; +export const initWorkflow = (): Workflow => { + // 开始节点 + const rs = newWorkflowNode(WorkflowNodeType.Start, {}); + let root = rs; + + // 申请节点 + root.next = newWorkflowNode(WorkflowNodeType.Apply, {}); + root = root.next; + + // 部署节点 + root.next = newWorkflowNode(WorkflowNodeType.Deploy, {}); + root = root.next; + + // 通知节点 + root.next = newWorkflowNode(WorkflowNodeType.Notify, {}); + + return { + name: i18n.t("workflow.default.name"), + type: "auto", + crontab: "0 0 * * *", + enabled: false, + draft: rs, + }; +}; + export const newWorkflowNode = (type: WorkflowNodeType, options: NewWorkflowNodeOptions): WorkflowNode | WorkflowBranchNode => { const id = nanoid(); const typeName = workflowNodeTypeDefaultName.get(type) || ""; diff --git a/ui/src/i18n/locales/en/nls.common.json b/ui/src/i18n/locales/en/nls.common.json index 69517f1e..41159d67 100644 --- a/ui/src/i18n/locales/en/nls.common.json +++ b/ui/src/i18n/locales/en/nls.common.json @@ -83,12 +83,12 @@ "common.provider.local": "Local Deployment", "common.provider.ssh": "SSH Deployment", "common.provider.webhook": "Webhook", - "common.provider.serverchan": "ServerChan", "common.provider.kubernetes": "Kubernetes", "common.provider.kubernetes.secret": "Kubernetes - Secret", + "common.provider.email": "Email", "common.provider.dingtalk": "DingTalk", - "common.provider.telegram": "Telegram", "common.provider.lark": "Lark", - "common.provider.mail": "Mail", + "common.provider.telegram": "Telegram", + "common.provider.serverchan": "ServerChan", "common.provider.bark": "Bark" } diff --git a/ui/src/i18n/locales/en/nls.settings.json b/ui/src/i18n/locales/en/nls.settings.json index a7d2f857..580c4ef1 100644 --- a/ui/src/i18n/locales/en/nls.settings.json +++ b/ui/src/i18n/locales/en/nls.settings.json @@ -30,20 +30,40 @@ "settings.notification.config.enable": "Enable", "settings.notification.config.saved.message": "Configuration saved successfully", "settings.notification.config.failed.message": "Configuration save failed", - "settings.notification.config.push.test.message": "Send test notification", - "settings.notification.config.push.test.message.failed.message": "Send test notification failed", - "settings.notification.config.push.test.message.success.message": "Send test notification successfully", - "settings.notification.dingtalk.secret.placeholder": "Signature for signed addition", - "settings.notification.url.errmsg.invalid": "Invalid Url format", - "settings.notification.serverchan.url.placeholder": "Url, e.g. https://sctapi.ftqq.com/****************.send", - "settings.notification.mail.sender_address.placeholder": "Sender email address", - "settings.notification.mail.receiver_address.placeholder": "Receiver email address", - "settings.notification.mail.smtp_host.placeholder": "SMTP server address", - "settings.notification.mail.smtp_port.placeholder": "SMTP server port, if not set, default is 25", - "settings.notification.mail.username.placeholder": "username", - "settings.notification.mail.password.placeholder": "password", - "settings.notification.bark.serverUrl.placeholder": "Server URL, e.g. https://your-bark-server.com, leave it blank to use the bark default server", - "settings.notification.bark.deviceKey.placeholder": "Device Key,e.g. XXXXXXXXXXXXXXXXXXXX", + "settings.notification.push_test_message": "Send test notification", + "settings.notification.push_test_message.succeeded.message": "Send test notification successfully", + "settings.notification.push_test_message.failed.message": "Send test notification failed", + "settings.notification.email.smtp_host.label": "SMTP Host", + "settings.notification.email.smtp_host.placeholder": "Please enter SMTP host", + "settings.notification.email.smtp_port.label": "SMTP Port", + "settings.notification.email.smtp_port.placeholder": "Please enter SMTP port", + "settings.notification.email.smtp_tls.label": "Use TLS/SSL", + "settings.notification.email.username.label": "Username", + "settings.notification.email.username.placeholder": "please enter username", + "settings.notification.email.password.label": "Password", + "settings.notification.email.password.placeholder": "please enter password", + "settings.notification.email.sender_address.label": "Sender Email Address", + "settings.notification.email.sender_address.placeholder": "Please enter sender email address", + "settings.notification.email.receiver_address.label": "Receiver Email Address", + "settings.notification.email.receiver_address.placeholder": "Please enter receiver email address", + "settings.notification.webhook.url.label": "Webhook URL", + "settings.notification.webhook.url.placeholder": "Please enter Webhook URL", + "settings.notification.dingtalk.access_token.label": "AccessToken", + "settings.notification.dingtalk.access_token.placeholder": "Please enter access token", + "settings.notification.dingtalk.secret.label": "Secret", + "settings.notification.dingtalk.secret.placeholder": "Please enter secret", + "settings.notification.lark.webhook_url.label": "Webhook URL", + "settings.notification.lark.webhook_url.placeholder": "Please enter Webhook URL", + "settings.notification.telegram.api_token.label": "API Token", + "settings.notification.telegram.api_token.placeholder": "Please enter API token", + "settings.notification.telegram.chat_id.label": "Chat ID", + "settings.notification.telegram.chat_id.placeholder": "Please enter Telegram chat ID", + "settings.notification.serverchan.url.label": "Server URL", + "settings.notification.serverchan.url.placeholder": "Please enter server URL (e.g. https://sctapi.ftqq.com/*****.send)", + "settings.notification.bark.server_url.label": "Server URL", + "settings.notification.bark.server_url.placeholder": "Please enter server URL (e.g. https://your-bark-server.com. Leave it blank to use the bark default server)", + "settings.notification.bark.device_key.label": "Device Key", + "settings.notification.bark.device_key.placeholder": "Please enter device key", "settings.ca.tab": "Certificate Authority", "settings.ca.provider.errmsg.empty": "Please select a Certificate Authority", diff --git a/ui/src/i18n/locales/zh/nls.common.json b/ui/src/i18n/locales/zh/nls.common.json index 5657836e..f14729a5 100644 --- a/ui/src/i18n/locales/zh/nls.common.json +++ b/ui/src/i18n/locales/zh/nls.common.json @@ -83,12 +83,12 @@ "common.provider.local": "本地部署", "common.provider.ssh": "SSH 部署", "common.provider.webhook": "Webhook", - "common.provider.serverchan": "Server酱", "common.provider.kubernetes": "Kubernetes", "common.provider.kubernetes.secret": "Kubernetes - Secret", + "common.provider.email": "电子邮件", "common.provider.dingtalk": "钉钉", - "common.provider.telegram": "Telegram", "common.provider.lark": "飞书", - "common.provider.mail": "电子邮件", + "common.provider.telegram": "Telegram", + "common.provider.serverchan": "Server酱", "common.provider.bark": "Bark" } diff --git a/ui/src/i18n/locales/zh/nls.settings.json b/ui/src/i18n/locales/zh/nls.settings.json index 23185807..9f6f7f28 100644 --- a/ui/src/i18n/locales/zh/nls.settings.json +++ b/ui/src/i18n/locales/zh/nls.settings.json @@ -30,20 +30,40 @@ "settings.notification.config.enable": "是否启用", "settings.notification.config.saved.message": "配置保存成功", "settings.notification.config.failed.message": "配置保存失败", - "settings.notification.config.push.test.message": "推送测试消息", - "settings.notification.config.push.test.message.failed.message": "推送测试消息失败", - "settings.notification.config.push.test.message.success.message": "推送测试消息成功", - "settings.notification.dingtalk.secret.placeholder": "加签的签名", - "settings.notification.url.errmsg.invalid": "URL 格式不正确", - "settings.notification.serverchan.url.placeholder": "Url, 形如: https://sctapi.ftqq.com/****************.send", - "settings.notification.mail.sender_address.placeholder": "发送邮箱地址", - "settings.notification.mail.receiver_address.placeholder": "接收邮箱地址", - "settings.notification.mail.smtp_host.placeholder": "SMTP服务器地址", - "settings.notification.mail.smtp_port.placeholder": "SMTP服务器端口, 如果未设置, 默认为25", - "settings.notification.mail.username.placeholder": "用于登录到邮件服务器的用户名", - "settings.notification.mail.password.placeholder": "用于登录到邮件服务器的密码", - "settings.notification.bark.serverUrl.placeholder": "服务器URL,形如: https://your-bark-server.com, 留空则使用 Bark 默认服务器", - "settings.notification.bark.deviceKey.placeholder": "设备密钥,形如: XXXXXXXXXXXXXXXXXXXX", + "settings.notification.push_test_message": "推送测试消息", + "settings.notification.push_test_message.failed.message": "推送测试消息失败", + "settings.notification.push_test_message.succeeded.message": "推送测试消息成功", + "settings.notification.email.smtp_host.label": "SMTP 服务器地址", + "settings.notification.email.smtp_host.placeholder": "请输入 SMTP 服务器地址", + "settings.notification.email.smtp_port.label": "SMTP 服务器端口", + "settings.notification.email.smtp_port.placeholder": "请输入 SMTP 服务器端口", + "settings.notification.email.smtp_tls.label": "TLS/SSL 连接", + "settings.notification.email.username.label": "用户名", + "settings.notification.email.username.placeholder": "请输入用户名", + "settings.notification.email.password.label": "密码", + "settings.notification.email.password.placeholder": "请输入密码", + "settings.notification.email.sender_address.label": "发送邮箱地址", + "settings.notification.email.sender_address.placeholder": "请输入发送邮箱地址", + "settings.notification.email.receiver_address.label": "接收邮箱地址", + "settings.notification.email.receiver_address.placeholder": "请输入接收邮箱地址", + "settings.notification.webhook.url.label": "Webhook 回调地址", + "settings.notification.webhook.url.placeholder": "请输入 Webhook 回调地址", + "settings.notification.dingtalk.access_token.label": "AccessToken", + "settings.notification.dingtalk.access_token.placeholder": "请输入 AccessToken", + "settings.notification.dingtalk.secret.label": "签名密钥", + "settings.notification.dingtalk.secret.placeholder": "请输入签名密钥", + "settings.notification.lark.webhook_url.label": "Webhook URL", + "settings.notification.lark.webhook_url.placeholder": "请输入 Webhook URL", + "settings.notification.telegram.api_token.label": "API Token", + "settings.notification.telegram.api_token.placeholder": "请输入 API token", + "settings.notification.telegram.chat_id.label": "会话 ID", + "settings.notification.telegram.chat_id.placeholder": "请输入 Telegram 会话 ID", + "settings.notification.serverchan.url.label": "服务器 URL", + "settings.notification.serverchan.url.placeholder": "请输入服务器 URL(形如: https://sctapi.ftqq.com/*****.send)", + "settings.notification.bark.server_url.label": "服务器 URL", + "settings.notification.bark.server_url.placeholder": "请输入服务器 URL(形如: https://your-bark-server.com;留空则使用 Bark 默认服务器)", + "settings.notification.bark.device_key.label": "设备密钥", + "settings.notification.bark.device_key.placeholder": "请输入设备密钥", "settings.ca.tab": "证书颁发机构(CA)", "settings.ca.provider.errmsg.empty": "请选择证书分发机构", diff --git a/ui/src/pages/setting/Notify.tsx b/ui/src/pages/setting/Notify.tsx index c29d0405..c89d2a24 100644 --- a/ui/src/pages/setting/Notify.tsx +++ b/ui/src/pages/setting/Notify.tsx @@ -7,7 +7,7 @@ import NotifyTemplate from "@/components/notify/NotifyTemplate"; import Telegram from "@/components/notify/Telegram"; import Webhook from "@/components/notify/Webhook"; import ServerChan from "@/components/notify/ServerChan"; -import Mail from "@/components/notify/Mail"; +import Email from "@/components/notify/Email"; import Bark from "@/components/notify/Bark"; import { NotifyProvider } from "@/providers/notify"; @@ -27,51 +27,52 @@ const Notify = () => {
+
- - {t("common.provider.dingtalk")} + + {t("common.provider.email")} - + - - {t("common.provider.lark")} - - - - - - - {t("common.provider.telegram")} - - - - - - + {t("common.provider.webhook")} - + + {t("common.provider.dingtalk")} + + + + + + + {t("common.provider.lark")} + + + + + + + {t("common.provider.telegram")} + + + + + + {t("common.provider.serverchan")} - - {t("common.provider.mail")} - - - - - - + {t("common.provider.bark")} diff --git a/ui/src/pages/workflow/WorkflowDetail.tsx b/ui/src/pages/workflow/WorkflowDetail.tsx new file mode 100644 index 00000000..bee32a2d --- /dev/null +++ b/ui/src/pages/workflow/WorkflowDetail.tsx @@ -0,0 +1,102 @@ +import Show from "@/components/Show"; +import { Button } from "@/components/ui/button"; +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import { Switch } from "@/components/ui/switch"; +import End from "@/components/workflow/End"; +import NodeRender from "@/components/workflow/NodeRender"; + +import WorkflowProvider from "@/components/workflow/WorkflowProvider"; +import { WorkflowNode } from "@/domain/workflow"; +import { useWorkflowStore, WorkflowState } from "@/providers/workflow"; +import { ArrowLeft } from "lucide-react"; +import { useEffect, useMemo } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; + +import { useShallow } from "zustand/shallow"; + +const selectState = (state: WorkflowState) => ({ + workflow: state.workflow, + init: state.init, + switchEnable: state.switchEnable, + save: state.save, +}); + +const WorkflowDetail = () => { + // 3. 使用正确的选择器和 shallow 比较 + const { workflow, init, switchEnable, save } = useWorkflowStore(useShallow(selectState)); + + // 从 url 中获取 workflowId + const [searchParams] = useSearchParams(); + const id = searchParams.get("id"); + + useEffect(() => { + console.log(id); + init(id ?? ""); + }, [id]); + + const navigate = useNavigate(); + + const elements = useMemo(() => { + let current = workflow.draft as WorkflowNode; + + const elements: JSX.Element[] = []; + + while (current) { + // 处理普通节点 + elements.push(); + current = current.next as WorkflowNode; + } + + elements.push(); + + return elements; + }, [workflow]); + + const handleBackClick = () => { + navigate("/workflow"); + }; + + const handleEnableChange = () => { + switchEnable(); + }; + + const handleWorkflowSaveClick = () => { + save(); + }; + + return ( + <> + + +
+
+ +
+
工作流
+
工作流详情
+
+
+
+ + 立即执行}> + + + + + +
+
+ +
{elements}
+ + + +
+
+ + ); +}; + +export default WorkflowDetail; diff --git a/ui/src/pages/workflow/index.tsx b/ui/src/pages/workflow/index.tsx index 11195938..594b0a56 100644 --- a/ui/src/pages/workflow/index.tsx +++ b/ui/src/pages/workflow/index.tsx @@ -1,50 +1,21 @@ -import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; -import End from "@/components/workflow/End"; -import NodeRender from "@/components/workflow/NodeRender"; - -import WorkflowProvider from "@/components/workflow/WorkflowProvider"; -import { WorkflowNode } from "@/domain/workflow"; -import { useWorkflowStore, WorkflowState } from "@/providers/workflow"; -import { useMemo } from "react"; - -import { useShallow } from "zustand/shallow"; - -const selectState = (state: WorkflowState) => ({ - root: state.root, -}); +import { Button } from "@/components/ui/button"; +import { Plus } from "lucide-react"; +import { useNavigate } from "react-router-dom"; const Workflow = () => { - // 3. 使用正确的选择器和 shallow 比较 - const { root } = useWorkflowStore(useShallow(selectState)); - - const elements = useMemo(() => { - let current = root; - - const elements: JSX.Element[] = []; - - while (current) { - // 处理普通节点 - elements.push(); - current = current.next as WorkflowNode; - } - - elements.push(); - - return elements; - }, [root]); - + const navigate = useNavigate(); + const handleCreateClick = () => { + navigate("/workflow/detail"); + }; return ( <> - - -
- -
{elements}
- - - -
-
+
+
工作流
+ +
); }; diff --git a/ui/src/providers/workflow/index.ts b/ui/src/providers/workflow/index.ts index d644799d..0cb688f3 100644 --- a/ui/src/providers/workflow/index.ts +++ b/ui/src/providers/workflow/index.ts @@ -2,99 +2,183 @@ import { addBranch, addNode, getWorkflowOutputBeforeId, + initWorkflow, removeBranch, removeNode, updateNode, + Workflow, WorkflowBranchNode, WorkflowNode, WorkflowNodeType, } from "@/domain/workflow"; +import { save, get as getWrokflow } from "@/repository/workflow"; import { create } from "zustand"; export type WorkflowState = { - root: WorkflowNode; + workflow: Workflow; + initialized: boolean; updateNode: (node: WorkflowNode) => void; addNode: (node: WorkflowNode, preId: string) => void; addBranch: (branchId: string) => void; removeNode: (nodeId: string) => void; removeBranch: (branchId: string, index: number) => void; getWorkflowOuptutBeforeId: (id: string, type: string) => WorkflowNode[]; + switchEnable(): void; + save(): void; + init(id?: string): void; }; export const useWorkflowStore = create((set, get) => ({ - root: { - id: "1", - name: "开始", + workflow: { + id: "root", + name: "placeholder", type: WorkflowNodeType.Start, - next: { - id: "2", - name: "分支", - type: WorkflowNodeType.Branch, - branches: [ - { - id: "3", - name: "条件1", - type: WorkflowNodeType.Condition, - next: { - id: "4", - name: "条件2", - type: WorkflowNodeType.Apply, - }, - }, - { - id: "5", - name: "条件2", - type: WorkflowNodeType.Condition, - }, - ], - }, }, - updateNode: (node: WorkflowNode | WorkflowBranchNode) => { + initialized: false, + init: async (id?: string) => { + let data = { + name: "placeholder", + type: "auto", + }; + + if (!id) { + data = initWorkflow(); + } else { + data = await getWrokflow(id); + } + + set({ + workflow: data, + initialized: true, + }); + }, + switchEnable: async () => { + const resp = await save({ + id: (get().workflow.id as string) ?? "", + content: get().workflow.draft as WorkflowNode, + enabled: !get().workflow.enabled, + hasDraft: false, + }); set((state: WorkflowState) => { - const newRoot = updateNode(state.root, node); - console.log(newRoot); return { - root: newRoot, + workflow: { + ...state.workflow, + id: resp.id, + content: resp.content, + enabled: resp.enabled, + hasDraft: false, + }, }; }); }, - addNode: (node: WorkflowNode | WorkflowBranchNode, preId: string) => + save: async () => { + const resp = await save({ + id: (get().workflow.id as string) ?? "", + content: get().workflow.draft as WorkflowNode, + hasDraft: false, + }); set((state: WorkflowState) => { - const newRoot = addNode(state.root, preId, node); - return { - root: newRoot, + workflow: { + ...state.workflow, + id: resp.id, + content: resp.content, + hasDraft: false, + }, }; - }), - addBranch: (branchId: string) => + }); + }, + updateNode: async (node: WorkflowNode | WorkflowBranchNode) => { + const newRoot = updateNode(get().workflow.draft as WorkflowNode, node); + const resp = await save({ + id: (get().workflow.id as string) ?? "", + draft: newRoot, + hasDraft: true, + }); set((state: WorkflowState) => { - const newRoot = addBranch(state.root, branchId); - return { - root: newRoot, + workflow: { + ...state.workflow, + draft: newRoot, + id: resp.id, + hasDraft: true, + }, }; - }), - - removeBranch: (branchId: string, index: number) => + }); + }, + addNode: async (node: WorkflowNode | WorkflowBranchNode, preId: string) => { + const newRoot = addNode(get().workflow.draft as WorkflowNode, preId, node); + const resp = await save({ + id: (get().workflow.id as string) ?? "", + draft: newRoot, + hasDraft: true, + }); set((state: WorkflowState) => { - const newRoot = removeBranch(state.root, branchId, index); - return { - root: newRoot, + workflow: { + ...state.workflow, + draft: newRoot, + id: resp.id, + hasDraft: true, + }, }; - }), - - removeNode: (nodeId: string) => + }); + }, + addBranch: async (branchId: string) => { + const newRoot = addBranch(get().workflow.draft as WorkflowNode, branchId); + const resp = await save({ + id: (get().workflow.id as string) ?? "", + draft: newRoot, + hasDraft: true, + }); set((state: WorkflowState) => { - const newRoot = removeNode(state.root, nodeId); - return { - root: newRoot, + workflow: { + ...state.workflow, + draft: newRoot, + id: resp.id, + hasDraft: true, + }, }; - }), - + }); + }, + removeBranch: async (branchId: string, index: number) => { + const newRoot = removeBranch(get().workflow.draft as WorkflowNode, branchId, index); + const resp = await save({ + id: (get().workflow.id as string) ?? "", + draft: newRoot, + hasDraft: true, + }); + set((state: WorkflowState) => { + return { + workflow: { + ...state.workflow, + draft: newRoot, + id: resp.id, + hasDraft: true, + }, + }; + }); + }, + removeNode: async (nodeId: string) => { + const newRoot = removeNode(get().workflow.draft as WorkflowNode, nodeId); + const resp = await save({ + id: (get().workflow.id as string) ?? "", + draft: newRoot, + hasDraft: true, + }); + set((state: WorkflowState) => { + return { + workflow: { + ...state.workflow, + draft: newRoot, + id: resp.id, + hasDraft: true, + }, + }; + }); + }, getWorkflowOuptutBeforeId: (id: string, type: string) => { - return getWorkflowOutputBeforeId(get().root, id, type); + return getWorkflowOutputBeforeId(get().workflow.draft as WorkflowNode, id, type); }, })); - diff --git a/ui/src/repository/workflow.ts b/ui/src/repository/workflow.ts new file mode 100644 index 00000000..f5600876 --- /dev/null +++ b/ui/src/repository/workflow.ts @@ -0,0 +1,16 @@ +import { Workflow, WorkflowNode } from "@/domain/workflow"; +import { getPb } from "./api"; + +export const get = async (id: string) => { + const response = await getPb().collection("workflow").getOne(id); + return response; +}; + +export const save = async (data: Record) => { + if (data.id) { + return await getPb() + .collection("workflow") + .update(data.id as string, data); + } + return await getPb().collection("workflow").create(data); +}; diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 897e13d2..dc33cfd6 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -14,6 +14,7 @@ import Account from "./pages/setting/Account"; import Notify from "./pages/setting/Notify"; import SSLProvider from "./pages/setting/SSLProvider"; import Workflow from "./pages/workflow"; +import WorkflowDetail from "./pages/workflow/WorkflowDetail"; export const router = createHashRouter([ { @@ -40,6 +41,10 @@ export const router = createHashRouter([ path: "/history", element: , }, + { + path: "/workflow", + element: , + }, { path: "/setting", element: , @@ -75,7 +80,7 @@ export const router = createHashRouter([ ], }, { - path: "/about", - element: , + path: "/workflow/detail", + element: , }, ]);