workflow data save

This commit is contained in:
yoan 2024-11-12 13:16:23 +08:00
commit 35c0ed2ba5
53 changed files with 2053 additions and 796 deletions

2
go.mod
View File

@ -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

View File

@ -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)
}
// 以变量字典形式获取配置项。

View File

@ -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"
)

View File

@ -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

View File

@ -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 &notifyMessage{
Subject: subject,
Message: message,
}
}

View File

@ -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(&notifierEmail.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(&notifierWebhook.WebhookNotifierConfig{
Url: maps.GetValueAsString(channelConfig, "url"),
})
case domain.NotifyChannelDingtalk:
return notifierDingTalk.New(&notifierDingTalk.DingTalkNotifierConfig{
AccessToken: maps.GetValueAsString(channelConfig, "accessToken"),
Secret: maps.GetValueAsString(channelConfig, "secret"),
})
case domain.NotifyChannelLark:
return notifierLark.New(&notifierLark.LarkNotifierConfig{
WebhookUrl: maps.GetValueAsString(channelConfig, "webhookUrl"),
})
case domain.NotifyChannelTelegram:
return notifierTelegram.New(&notifierTelegram.TelegramNotifierConfig{
ApiToken: maps.GetValueAsString(channelConfig, "apiToken"),
ChatId: maps.GetValueAsInt64(channelConfig, "chatId"),
})
case domain.NotifyChannelServerChan:
return notifierServerChan.New(&notifierServerChan.ServerChanNotifierConfig{
Url: maps.GetValueAsString(channelConfig, "url"),
})
case domain.NotifyChannelBark:
return notifierBark.New(&notifierBark.BarkNotifierConfig{
DeviceKey: maps.GetValueAsString(channelConfig, "deviceKey"),
ServerUrl: maps.GetValueAsString(channelConfig, "serverUrl"),
})
}
return nil, errors.New("unsupported notifier channel")
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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"`
}

View File

@ -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 &notifier.NotifyResult{}, nil
}

View File

@ -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 &notifier.NotifyResult{}, nil
}

View File

@ -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 &notifier.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,
}
}

View File

@ -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(&notifierEmail.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)
}

View File

@ -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 &notifier.NotifyResult{}, nil
}

View File

@ -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(&notifyHttp.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 &notifier.NotifyResult{}, nil
}

View File

@ -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 &notifier.NotifyResult{}, nil
}

View File

@ -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 &notifier.NotifyResult{}, nil
}

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -2,7 +2,7 @@
import "context"
// 表示定义证书上传的抽象类型接口。
// 表示定义证书上传的抽象类型接口。
// 云服务商通常会提供 SSL 证书管理服务,可供用户集中管理证书。
// 注意与 `Deployer` 区分,“上传”通常为“部署”的前置操作。
type Uploader interface {

View File

@ -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
}

View File

@ -19,4 +19,3 @@ export const notifyTest = async (channel: string) => {
return resp;
};

View File

@ -123,22 +123,29 @@ const Bark = () => {
}
};
const [testing, setTesting] = useState<boolean>(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 (
<div>
<Input
placeholder={t("settings.notification.bark.serverUrl.placeholder")}
value={bark.data.serverUrl}
onChange={(e) => {
const newData = {
...bark,
data: {
...bark.data,
serverUrl: e.target.value,
},
};
<div className="flex flex-col space-y-4">
<div>
<Label>{t("settings.notification.bark.server_url.label")}</Label>
<Input
placeholder={t("settings.notification.bark.server_url.placeholder")}
value={bark.data.serverUrl}
onChange={(e) => {
const newData = {
...bark,
data: {
...bark.data,
serverUrl: e.target.value,
},
};
checkChanged(newData.data);
setBark(newData);
}}
/>
<Input
placeholder={t("settings.notification.bark.deviceKey.placeholder")}
value={bark.data.deviceKey}
onChange={(e) => {
const newData = {
...bark,
data: {
...bark.data,
deviceKey: e.target.value,
},
};
checkChanged(newData.data);
setBark(newData);
}}
/>
<div className="flex items-center space-x-1 mt-2">
<Switch id="airplane-mode" checked={bark.data.enabled} onCheckedChange={handleSwitchChange} />
<Label htmlFor="airplane-mode">{t("settings.notification.config.enable")}</Label>
checkChanged(newData.data);
setBark(newData);
}}
/>
</div>
<div className="flex justify-end mt-2">
<Show when={changed}>
<Button
onClick={() => {
handleSaveClick();
}}
>
{t("common.save")}
</Button>
</Show>
<div>
<Label>{t("settings.notification.bark.device_key.label")}</Label>
<Input
placeholder={t("settings.notification.bark.device_key.placeholder")}
value={bark.data.deviceKey}
onChange={(e) => {
const newData = {
...bark,
data: {
...bark.data,
deviceKey: e.target.value,
},
};
<Show when={!changed && bark.id != ""}>
<Button
variant="secondary"
onClick={() => {
handlePushTestClick();
}}
>
{t("settings.notification.config.push.test.message")}
</Button>
</Show>
checkChanged(newData.data);
setBark(newData);
}}
/>
</div>
<div className="flex justify-between gap-4">
<div className="flex items-center space-x-1">
<Switch id="airplane-mode" checked={bark.data.enabled} onCheckedChange={handleSwitchChange} />
<Label htmlFor="airplane-mode">{t("settings.notification.config.enable")}</Label>
</div>
<div className="flex items-center space-x-1">
<Show when={changed}>
<Button
onClick={() => {
handleSaveClick();
}}
>
{t("common.save")}
</Button>
</Show>
<Show when={!changed && bark.id != ""}>
<Button
variant="secondary"
loading={testing}
onClick={() => {
handlePushTestClick();
}}
>
{t("settings.notification.push_test_message")}
</Button>
</Show>
</div>
</div>
</div>
);

View File

@ -120,23 +120,30 @@ const DingTalk = () => {
description: `${t("settings.notification.config.failed.message")}: ${msg}`,
variant: "destructive",
});
} finally {
setTesting(false);
}
};
const [testing, setTesting] = useState<boolean>(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 (
<div>
<Input
placeholder="AccessToken"
value={dingtalk.data.accessToken}
onChange={(e) => {
const newData = {
...dingtalk,
data: {
...dingtalk.data,
accessToken: e.target.value,
},
};
checkChanged(newData.data);
setDingtalk(newData);
}}
/>
<Input
placeholder={t("settings.notification.dingtalk.secret.placeholder")}
className="mt-2"
value={dingtalk.data.secret}
onChange={(e) => {
const newData = {
...dingtalk,
data: {
...dingtalk.data,
secret: e.target.value,
},
};
checkChanged(newData.data);
setDingtalk(newData);
}}
/>
<div className="flex items-center space-x-1 mt-2">
<Switch id="airplane-mode" checked={dingtalk.data.enabled} onCheckedChange={handleSwitchChange} />
<Label htmlFor="airplane-mode">{t("settings.notification.config.enable")}</Label>
<div className="flex flex-col space-y-4">
<div>
<Label>{t("settings.notification.dingtalk.access_token.label")}</Label>
<Input
placeholder={t("settings.notification.dingtalk.access_token.placeholder")}
value={dingtalk.data.accessToken}
onChange={(e) => {
const newData = {
...dingtalk,
data: {
...dingtalk.data,
accessToken: e.target.value,
},
};
checkChanged(newData.data);
setDingtalk(newData);
}}
/>
</div>
<div className="flex justify-end mt-2">
<Show when={changed}>
<Button
onClick={() => {
handleSaveClick();
}}
>
{t("common.save")}
</Button>
</Show>
<div>
<Label>{t("settings.notification.dingtalk.secret.label")}</Label>
<Input
placeholder={t("settings.notification.dingtalk.secret.placeholder")}
value={dingtalk.data.secret}
onChange={(e) => {
const newData = {
...dingtalk,
data: {
...dingtalk.data,
secret: e.target.value,
},
};
checkChanged(newData.data);
setDingtalk(newData);
}}
/>
</div>
<Show when={!changed && dingtalk.id != ""}>
<Button
variant="secondary"
onClick={() => {
handlePushTestClick();
}}
>
{t("settings.notification.config.push.test.message")}
</Button>
</Show>
<div className="flex justify-between gap-4">
<div className="flex items-center space-x-1">
<Switch id="airplane-mode" checked={dingtalk.data.enabled} onCheckedChange={handleSwitchChange} />
<Label htmlFor="airplane-mode">{t("settings.notification.config.enable")}</Label>
</div>
<div className="flex items-center space-x-1">
<Show when={changed}>
<Button
onClick={() => {
handleSaveClick();
}}
>
{t("common.save")}
</Button>
</Show>
<Show when={!changed && dingtalk.id != ""}>
<Button
variant="secondary"
loading={testing}
onClick={() => {
handlePushTestClick();
}}
>
{t("settings.notification.push_test_message")}
</Button>
</Show>
</div>
</div>
</div>
);

View File

@ -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<boolean>(false);
const [mail, setMail] = useState<EmailSetting>({
id: config.id ?? "",
name: "notifyChannels",
data: {
smtpHost: "",
smtpPort: 465,
smtpTLS: true,
username: "",
password: "",
senderAddress: "",
receiverAddress: "",
enabled: false,
},
});
const [originMail, setOriginMail] = useState<EmailSetting>({
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<boolean>(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 (
<div className="flex flex-col space-y-4">
<div className="flex space-x-4">
<div className="w-2/5">
<Label>{t("settings.notification.email.smtp_host.label")}</Label>
<Input
placeholder={t("settings.notification.email.smtp_host.placeholder")}
value={mail.data.smtpHost}
onChange={(e) => {
const newData = {
...mail,
data: {
...mail.data,
smtpHost: e.target.value,
},
};
checkChanged(newData.data);
setMail(newData);
}}
/>
</div>
<div className="w-2/5">
<Label>{t("settings.notification.email.smtp_port.label")}</Label>
<Input
type="number"
placeholder={t("settings.notification.email.smtp_port.placeholder")}
value={mail.data.smtpPort}
onChange={(e) => {
const newData = {
...mail,
data: {
...mail.data,
smtpPort: +e.target.value || 0,
},
};
checkChanged(newData.data);
setMail(newData);
}}
/>
</div>
<div className="w-1/5">
<Label>{t("settings.notification.email.smtp_tls.label")}</Label>
<Switch
className="block mt-2"
checked={mail.data.smtpTLS}
onCheckedChange={(e) => {
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);
}}
/>
</div>
</div>
<div className="flex space-x-4">
<div className="w-1/2">
<Label>{t("settings.notification.email.username.label")}</Label>
<Input
placeholder={t("settings.notification.email.username.placeholder")}
value={mail.data.username}
onChange={(e) => {
const newData = {
...mail,
data: {
...mail.data,
username: e.target.value,
},
};
checkChanged(newData.data);
setMail(newData);
}}
/>
</div>
<div className="w-1/2">
<Label>{t("settings.notification.email.password.label")}</Label>
<Input
placeholder={t("settings.notification.email.password.placeholder")}
value={mail.data.password}
onChange={(e) => {
const newData = {
...mail,
data: {
...mail.data,
password: e.target.value,
},
};
checkChanged(newData.data);
setMail(newData);
}}
/>
</div>
</div>
<div>
<Label>{t("settings.notification.email.sender_address.label")}</Label>
<Input
placeholder={t("settings.notification.email.sender_address.placeholder")}
value={mail.data.senderAddress}
onChange={(e) => {
const newData = {
...mail,
data: {
...mail.data,
senderAddress: e.target.value,
},
};
checkChanged(newData.data);
setMail(newData);
}}
/>
</div>
<div>
<Label>{t("settings.notification.email.receiver_address.label")}</Label>
<Input
placeholder={t("settings.notification.email.receiver_address.placeholder")}
value={mail.data.receiverAddress}
onChange={(e) => {
const newData = {
...mail,
data: {
...mail.data,
receiverAddress: e.target.value,
},
};
checkChanged(newData.data);
setMail(newData);
}}
/>
</div>
<div className="flex justify-between gap-4">
<div className="flex items-center space-x-1">
<Switch id="airplane-mode" checked={mail.data.enabled} onCheckedChange={handleSwitchChange} />
<Label htmlFor="airplane-mode">{t("settings.notification.config.enable")}</Label>
</div>
<div className="flex items-center space-x-1">
<Show when={changed}>
<Button
onClick={() => {
handleSaveClick();
}}
>
{t("common.save")}
</Button>
</Show>
<Show when={!changed && mail.id != ""}>
<Button
variant="secondary"
loading={testing}
onClick={() => {
handlePushTestClick();
}}
>
{t("settings.notification.push_test_message")}
</Button>
</Show>
</div>
</div>
</div>
);
};
export default Mail;

View File

@ -116,23 +116,30 @@ const Lark = () => {
description: `${t("settings.notification.config.failed.message")}: ${msg}`,
variant: "destructive",
});
} finally {
setTesting(false);
}
};
const [testing, setTesting] = useState<boolean>(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 (
<div>
<Input
placeholder="Webhook Url"
value={lark.data.webhookUrl}
onChange={(e) => {
const newData = {
...lark,
data: {
...lark.data,
webhookUrl: e.target.value,
},
};
<div className="flex flex-col space-y-4">
<div>
<Label>{t("settings.notification.lark.webhook_url.label")}</Label>
<Input
placeholder={t("settings.notification.lark.webhook_url.placeholder")}
value={lark.data.webhookUrl}
onChange={(e) => {
const newData = {
...lark,
data: {
...lark.data,
webhookUrl: e.target.value,
},
};
checkChanged(newData.data);
setLark(newData);
}}
/>
<div className="flex items-center space-x-1 mt-2">
<Switch id="airplane-mode" checked={lark.data.enabled} onCheckedChange={handleSwitchChange} />
<Label htmlFor="airplane-mode">{t("settings.notification.config.enable")}</Label>
checkChanged(newData.data);
setLark(newData);
}}
/>
</div>
<div className="flex justify-end mt-2">
<Show when={changed}>
<Button
onClick={() => {
handleSaveClick();
}}
>
{t("common.save")}
</Button>
</Show>
<div className="flex justify-between gap-4">
<div className="flex items-center space-x-1">
<Switch id="airplane-mode" checked={lark.data.enabled} onCheckedChange={handleSwitchChange} />
<Label htmlFor="airplane-mode">{t("settings.notification.config.enable")}</Label>
</div>
<Show when={!changed && lark.id != ""}>
<Button
variant="secondary"
onClick={() => {
handlePushTestClick();
}}
>
{t("settings.notification.config.push.test.message")}
</Button>
</Show>
<div className="flex items-center space-x-1">
<Show when={changed}>
<Button
onClick={() => {
handleSaveClick();
}}
>
{t("common.save")}
</Button>
</Show>
<Show when={!changed && lark.id != ""}>
<Button
variant="secondary"
loading={testing}
onClick={() => {
handlePushTestClick();
}}
>
{t("settings.notification.push_test_message")}
</Button>
</Show>
</div>
</div>
</div>
);

View File

@ -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<boolean>(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 (
<div>
<Input
placeholder={t("settings.notification.serverchan.url.placeholder")}
value={serverchan.data.url}
onChange={(e) => {
const newData = {
...serverchan,
data: {
...serverchan.data,
url: e.target.value,
},
};
<div className="flex flex-col space-y-4">
<div>
<Label>{t("settings.notification.serverchan.url.label")}</Label>
<Input
placeholder={t("settings.notification.serverchan.url.placeholder")}
value={serverchan.data.url}
onChange={(e) => {
const newData = {
...serverchan,
data: {
...serverchan.data,
url: e.target.value,
},
};
checkChanged(newData.data);
setServerChan(newData);
}}
/>
<div className="flex items-center space-x-1 mt-2">
<Switch id="airplane-mode" checked={serverchan.data.enabled} onCheckedChange={handleSwitchChange} />
<Label htmlFor="airplane-mode">{t("settings.notification.config.enable")}</Label>
checkChanged(newData.data);
setServerChan(newData);
}}
/>
</div>
<div className="flex justify-end mt-2">
<Show when={changed}>
<Button
onClick={() => {
handleSaveClick();
}}
>
{t("common.save")}
</Button>
</Show>
<div className="flex justify-between gap-4">
<div className="flex items-center space-x-1">
<Switch id="airplane-mode" checked={serverchan.data.enabled} onCheckedChange={handleSwitchChange} />
<Label htmlFor="airplane-mode">{t("settings.notification.config.enable")}</Label>
</div>
<Show when={!changed && serverchan.id != ""}>
<Button
variant="secondary"
onClick={() => {
handlePushTestClick();
}}
>
{t("settings.notification.config.push.test.message")}
</Button>
</Show>
<div className="flex items-center space-x-1">
<Show when={changed}>
<Button
onClick={() => {
handleSaveClick();
}}
>
{t("common.save")}
</Button>
</Show>
<Show when={!changed && serverchan.id != ""}>
<Button
variant="secondary"
loading={testing}
onClick={() => {
handlePushTestClick();
}}
>
{t("settings.notification.push_test_message")}
</Button>
</Show>
</div>
</div>
</div>
);

View File

@ -123,22 +123,29 @@ const Telegram = () => {
}
};
const [testing, setTesting] = useState<boolean>(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 (
<div>
<Input
placeholder="ApiToken"
value={telegram.data.apiToken}
onChange={(e) => {
const newData = {
...telegram,
data: {
...telegram.data,
apiToken: e.target.value,
},
};
<div className="flex flex-col space-y-4">
<div>
<Label>{t("settings.notification.telegram.api_token.label")}</Label>
<Input
placeholder={t("settings.notification.telegram.api_token.placeholder")}
value={telegram.data.apiToken}
onChange={(e) => {
const newData = {
...telegram,
data: {
...telegram.data,
apiToken: e.target.value,
},
};
checkChanged(newData.data);
setTelegram(newData);
}}
/>
<Input
placeholder="ChatId"
value={telegram.data.chatId}
onChange={(e) => {
const newData = {
...telegram,
data: {
...telegram.data,
chatId: e.target.value,
},
};
checkChanged(newData.data);
setTelegram(newData);
}}
/>
<div className="flex items-center space-x-1 mt-2">
<Switch id="airplane-mode" checked={telegram.data.enabled} onCheckedChange={handleSwitchChange} />
<Label htmlFor="airplane-mode">{t("settings.notification.config.enable")}</Label>
checkChanged(newData.data);
setTelegram(newData);
}}
/>
</div>
<div className="flex justify-end mt-2">
<Show when={changed}>
<Button
onClick={() => {
handleSaveClick();
}}
>
{t("common.save")}
</Button>
</Show>
<div>
<Label>{t("settings.notification.telegram.chat_id.label")}</Label>
<Input
placeholder={t("settings.notification.telegram.chat_id.placeholder")}
value={telegram.data.chatId}
onChange={(e) => {
const newData = {
...telegram,
data: {
...telegram.data,
chatId: e.target.value,
},
};
<Show when={!changed && telegram.id != ""}>
<Button
variant="secondary"
onClick={() => {
handlePushTestClick();
}}
>
{t("settings.notification.config.push.test.message")}
</Button>
</Show>
checkChanged(newData.data);
setTelegram(newData);
}}
/>
</div>
<div className="flex justify-between gap-4">
<div className="flex items-center space-x-1">
<Switch id="airplane-mode" checked={telegram.data.enabled} onCheckedChange={handleSwitchChange} />
<Label htmlFor="airplane-mode">{t("settings.notification.config.enable")}</Label>
</div>
<div className="flex items-center space-x-1">
<Show when={changed}>
<Button
onClick={() => {
handleSaveClick();
}}
>
{t("common.save")}
</Button>
</Show>
<Show when={!changed && telegram.id != ""}>
<Button
variant="secondary"
loading={testing}
onClick={() => {
handlePushTestClick();
}}
>
{t("settings.notification.push_test_message")}
</Button>
</Show>
</div>
</div>
</div>
);

View File

@ -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<boolean>(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 (
<div>
<Input
placeholder="Url"
value={webhook.data.url}
onChange={(e) => {
const newData = {
...webhook,
data: {
...webhook.data,
url: e.target.value,
},
};
<div className="flex flex-col space-y-4">
<div>
<Label>{t("settings.notification.webhook.url.label")}</Label>
<Input
placeholder={t("settings.notification.webhook.url.placeholder")}
value={webhook.data.url}
onChange={(e) => {
const newData = {
...webhook,
data: {
...webhook.data,
url: e.target.value,
},
};
checkChanged(newData.data);
setWebhook(newData);
}}
/>
<div className="flex items-center space-x-1 mt-2">
<Switch id="airplane-mode" checked={webhook.data.enabled} onCheckedChange={handleSwitchChange} />
<Label htmlFor="airplane-mode">{t("settings.notification.config.enable")}</Label>
checkChanged(newData.data);
setWebhook(newData);
}}
/>
</div>
<div className="flex justify-end mt-2">
<Show when={changed}>
<Button
onClick={() => {
handleSaveClick();
}}
>
{t("common.save")}
</Button>
</Show>
<div className="flex justify-between gap-4">
<div className="flex items-center space-x-1">
<Switch id="airplane-mode" checked={webhook.data.enabled} onCheckedChange={handleSwitchChange} />
<Label htmlFor="airplane-mode">{t("settings.notification.config.enable")}</Label>
</div>
<Show when={!changed && webhook.id != ""}>
<Button
variant="secondary"
onClick={() => {
handlePushTestClick();
}}
>
{t("settings.notification.config.push.test.message")}
</Button>
</Show>
<div className="flex items-center space-x-1">
<Show when={changed}>
<Button
onClick={() => {
handleSaveClick();
}}
>
{t("common.save")}
</Button>
</Show>
<Show when={!changed && webhook.id != ""}>
<Button
variant="secondary"
loading={testing}
onClick={() => {
handlePushTestClick();
}}
>
{t("settings.notification.push_test_message")}
</Button>
</Show>
</div>
</div>
</div>
);

View File

@ -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<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
loading?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ className, variant, size, loading, asChild = false, children, ...props }, ref) => {
if (asChild) {
return (
<Slot ref={ref} {...props}>
<>
{React.Children.map(children as React.ReactElement, (child: React.ReactElement) => {
return React.cloneElement(child, {
className: cn(buttonVariants({ variant, size }), className),
children: (
<>
{loading && <Loader2 className={cn("h-4 w-4 animate-spin", children && "mr-2")} />}
{child.props.children}
</>
),
});
})}
</>
</Slot>
);
}
return (
<button className={cn(buttonVariants({ variant, size, className }))} disabled={loading} ref={ref} {...props}>
<>
{loading && <Loader2 className={cn("h-4 w-4 animate-spin", children && "mr-2")} />}
{children}
</>
</button>
);
});
Button.displayName = "Button";

View File

@ -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")}
</Button>

View File

@ -34,7 +34,7 @@ const ConditionNode = ({ data, branchId, branchIndex }: NodeProps) => {
</DropdownMenu>
<div className="w-[261px] flex flex-col justify-center text-foreground rounded-md bg-white px-5 py-5">
<div contentEditable suppressContentEditableWarning onBlur={handleNameBlur} className="text-center outline-slate-200">
<div contentEditable suppressContentEditableWarning onBlur={handleNameBlur} className="text-center outline-slate-200 dark:text-stone-600">
{data.name}
</div>
</div>

View File

@ -24,7 +24,7 @@ export type DeployFormProps = {
defaultProivder?: string;
};
const DeployForm = ({ data, defaultProivder }: DeployFormProps) => {
return getForm(data, defaultProivder);
return <div className="dark:text-stone-200">{getForm(data, defaultProivder)}</div>;
};
export default memo(DeployForm);

View File

@ -39,7 +39,6 @@ const Node = ({ data }: NodeProps) => {
};
const getSetting = () => {
console.log(data);
if (!data.validated) {
return <>{t(`${i18nPrefix}.setting.label`)}</>;
}

View File

@ -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"
>
<FormField
control={form.control}

View File

@ -22,12 +22,12 @@ export type NotifyChannels = {
};
export type NotifyChannel =
| NotifyChannelEmail
| NotifyChannelWebhook
| NotifyChannelDingTalk
| NotifyChannelLark
| NotifyChannelTelegram
| NotifyChannelWebhook
| NotifyChannelServerChan
| NotifyChannelMail
| NotifyChannelBark;
type ChannelLabel = {
@ -66,6 +66,21 @@ export const channels: ChannelLabel[] = [
];
export const channelLabelMap: Map<string, ChannelLabel> = 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;

View File

@ -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) || "";

View File

@ -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"
}

View File

@ -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 Keye.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",

View File

@ -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"
}

View File

@ -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": "请选择证书分发机构",

View File

@ -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 = () => {
</AccordionItem>
</Accordion>
</div>
<div className="border rounded-md p-5 mt-7 shadow-lg">
<Accordion type={"single"} className="dark:text-stone-200">
<AccordionItem value="item-2" className="dark:border-stone-200">
<AccordionTrigger>{t("common.provider.dingtalk")}</AccordionTrigger>
<AccordionItem value="item-email" className="dark:border-stone-200">
<AccordionTrigger>{t("common.provider.email")}</AccordionTrigger>
<AccordionContent>
<DingTalk />
<Email />
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3" className="dark:border-stone-200">
<AccordionTrigger>{t("common.provider.lark")}</AccordionTrigger>
<AccordionContent>
<Lark />
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-4" className="dark:border-stone-200">
<AccordionTrigger>{t("common.provider.telegram")}</AccordionTrigger>
<AccordionContent>
<Telegram />
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-5" className="dark:border-stone-200">
<AccordionItem value="item-webhook" className="dark:border-stone-200">
<AccordionTrigger>{t("common.provider.webhook")}</AccordionTrigger>
<AccordionContent>
<Webhook />
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-6" className="dark:border-stone-200">
<AccordionItem value="item-dingtalk" className="dark:border-stone-200">
<AccordionTrigger>{t("common.provider.dingtalk")}</AccordionTrigger>
<AccordionContent>
<DingTalk />
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-lark" className="dark:border-stone-200">
<AccordionTrigger>{t("common.provider.lark")}</AccordionTrigger>
<AccordionContent>
<Lark />
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-telegram" className="dark:border-stone-200">
<AccordionTrigger>{t("common.provider.telegram")}</AccordionTrigger>
<AccordionContent>
<Telegram />
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-serverchan" className="dark:border-stone-200">
<AccordionTrigger>{t("common.provider.serverchan")}</AccordionTrigger>
<AccordionContent>
<ServerChan />
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-7" className="dark:border-stone-200">
<AccordionTrigger>{t("common.provider.mail")}</AccordionTrigger>
<AccordionContent>
<Mail />
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-8" className="dark:border-stone-200">
<AccordionItem value="item-bark" className="dark:border-stone-200">
<AccordionTrigger>{t("common.provider.bark")}</AccordionTrigger>
<AccordionContent>
<Bark />

View File

@ -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(<NodeRender data={current} key={current.id} />);
current = current.next as WorkflowNode;
}
elements.push(<End key="workflow-end" />);
return elements;
}, [workflow]);
const handleBackClick = () => {
navigate("/workflow");
};
const handleEnableChange = () => {
switchEnable();
};
const handleWorkflowSaveClick = () => {
save();
};
return (
<>
<WorkflowProvider>
<ScrollArea className="h-[100vh] w-full relative bg-background">
<div className="h-16 sticky top-0 left-0 z-20 shadow-md bg-muted/40 flex justify-between items-center">
<div className="px-5 text-stone-700 dark:text-stone-200 flex items-center space-x-2">
<ArrowLeft className="cursor-pointer" onClick={handleBackClick} />
<div className="flex flex-col space-y-2">
<div className=""></div>
<div className="text-sm text-muted-foreground"></div>
</div>
</div>
<div className="px-5 flex items-center space-x-3">
<Show when={!!workflow.enabled}>
<Show when={!!workflow.hasDraft} fallback={<Button variant={"secondary"}></Button>}>
<Button variant={"secondary"} onClick={handleWorkflowSaveClick}>
</Button>
</Show>
</Show>
<Switch className="dark:data-[state=unchecked]:bg-stone-400" checked={workflow.enabled ?? false} onCheckedChange={handleEnableChange} />
</div>
</div>
<div className=" flex flex-col items-center mt-8">{elements}</div>
<ScrollBar orientation="vertical" />
<ScrollBar orientation="horizontal" />
</ScrollArea>
</WorkflowProvider>
</>
);
};
export default WorkflowDetail;

View File

@ -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(<NodeRender data={current} key={current.id} />);
current = current.next as WorkflowNode;
}
elements.push(<End key="workflow-end" />);
return elements;
}, [root]);
const navigate = useNavigate();
const handleCreateClick = () => {
navigate("/workflow/detail");
};
return (
<>
<WorkflowProvider>
<ScrollArea className="h-[100vh] w-full relative bg-background">
<div className="h-16 sticky top-0 left-0 z-20 shadow-md bg-muted/40"></div>
<div className=" flex flex-col items-center mt-8">{elements}</div>
<ScrollBar orientation="vertical" />
<ScrollBar orientation="horizontal" />
</ScrollArea>
</WorkflowProvider>
<div className="flex justify-between items-center">
<div className="text-muted-foreground"></div>
<Button onClick={handleCreateClick}>
<Plus size={16} />
</Button>
</div>
</>
);
};

View File

@ -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<WorkflowState>((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);
},
}));

View File

@ -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<Workflow>(id);
return response;
};
export const save = async (data: Record<string, string | boolean | WorkflowNode>) => {
if (data.id) {
return await getPb()
.collection("workflow")
.update<Workflow>(data.id as string, data);
}
return await getPb().collection("workflow").create<Workflow>(data);
};

View File

@ -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: <History />,
},
{
path: "/workflow",
element: <Workflow />,
},
{
path: "/setting",
element: <SettingLayout />,
@ -75,7 +80,7 @@ export const router = createHashRouter([
],
},
{
path: "/about",
element: <Workflow />,
path: "/workflow/detail",
element: <WorkflowDetail />,
},
]);