Merge pull request #768 from fudiwei/main

enhance & bugfix
This commit is contained in:
RHQYZ 2025-06-09 21:09:28 +08:00 committed by GitHub
commit 62e2ed2fb8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 401 additions and 150 deletions

View File

@ -106,12 +106,13 @@ type WorkflowNodeConfigForDeploy struct {
} }
type WorkflowNodeConfigForNotify struct { type WorkflowNodeConfigForNotify struct {
Channel string `json:"channel,omitempty"` // Deprecated: v0.4.x 将废弃 Channel string `json:"channel,omitempty"` // Deprecated: v0.4.x 将废弃
Provider string `json:"provider"` // 通知提供商 Provider string `json:"provider"` // 通知提供商
ProviderAccessId string `json:"providerAccessId"` // 通知提供商授权记录 ID ProviderAccessId string `json:"providerAccessId"` // 通知提供商授权记录 ID
ProviderConfig map[string]any `json:"providerConfig,omitempty"` // 通知提供商额外配置 ProviderConfig map[string]any `json:"providerConfig,omitempty"` // 通知提供商额外配置
Subject string `json:"subject"` // 通知主题 Subject string `json:"subject"` // 通知主题
Message string `json:"message"` // 通知内容 Message string `json:"message"` // 通知内容
SkipOnAllPrevSkipped bool `json:"skipOnAllPrevSkipped"` // 前序节点均已跳过时是否跳过
} }
type WorkflowNodeConfigForCondition struct { type WorkflowNodeConfigForCondition struct {
@ -128,7 +129,7 @@ func (n *WorkflowNode) GetConfigForApply() WorkflowNodeConfigForApply {
CAProvider: maputil.GetString(n.Config, "caProvider"), CAProvider: maputil.GetString(n.Config, "caProvider"),
CAProviderAccessId: maputil.GetString(n.Config, "caProviderAccessId"), CAProviderAccessId: maputil.GetString(n.Config, "caProviderAccessId"),
CAProviderConfig: maputil.GetKVMapAny(n.Config, "caProviderConfig"), CAProviderConfig: maputil.GetKVMapAny(n.Config, "caProviderConfig"),
KeyAlgorithm: maputil.GetString(n.Config, "keyAlgorithm"), KeyAlgorithm: maputil.GetOrDefaultString(n.Config, "keyAlgorithm", string(CertificateKeyAlgorithmTypeRSA2048)),
Nameservers: maputil.GetString(n.Config, "nameservers"), Nameservers: maputil.GetString(n.Config, "nameservers"),
DnsPropagationWait: maputil.GetInt32(n.Config, "dnsPropagationWait"), DnsPropagationWait: maputil.GetInt32(n.Config, "dnsPropagationWait"),
DnsPropagationTimeout: maputil.GetInt32(n.Config, "dnsPropagationTimeout"), DnsPropagationTimeout: maputil.GetInt32(n.Config, "dnsPropagationTimeout"),
@ -169,12 +170,13 @@ func (n *WorkflowNode) GetConfigForDeploy() WorkflowNodeConfigForDeploy {
func (n *WorkflowNode) GetConfigForNotify() WorkflowNodeConfigForNotify { func (n *WorkflowNode) GetConfigForNotify() WorkflowNodeConfigForNotify {
return WorkflowNodeConfigForNotify{ return WorkflowNodeConfigForNotify{
Channel: maputil.GetString(n.Config, "channel"), Channel: maputil.GetString(n.Config, "channel"),
Provider: maputil.GetString(n.Config, "provider"), Provider: maputil.GetString(n.Config, "provider"),
ProviderAccessId: maputil.GetString(n.Config, "providerAccessId"), ProviderAccessId: maputil.GetString(n.Config, "providerAccessId"),
ProviderConfig: maputil.GetKVMapAny(n.Config, "providerConfig"), ProviderConfig: maputil.GetKVMapAny(n.Config, "providerConfig"),
Subject: maputil.GetString(n.Config, "subject"), Subject: maputil.GetString(n.Config, "subject"),
Message: maputil.GetString(n.Config, "message"), Message: maputil.GetString(n.Config, "message"),
SkipOnAllPrevSkipped: maputil.GetBool(n.Config, "skipOnAllPrevSkipped"),
} }
} }

View File

@ -285,6 +285,7 @@ func (d *DeployerProvider) updateHttpsListenerCertificate(ctx context.Context, c
ClientToken: generateClientToken(), ClientToken: generateClientToken(),
ListenerPort: uint16(cloudHttpsListenerPort), ListenerPort: uint16(cloudHttpsListenerPort),
Scheduler: describeAppHTTPSListenersResp.ListenerList[0].Scheduler, Scheduler: describeAppHTTPSListenersResp.ListenerList[0].Scheduler,
CertIds: describeAppHTTPSListenersResp.ListenerList[0].CertIds,
AdditionalCertDomains: sliceutil.Map(describeAppHTTPSListenersResp.ListenerList[0].AdditionalCertDomains, func(domain bceappblb.AdditionalCertDomainsModel) bceappblb.AdditionalCertDomainsModel { AdditionalCertDomains: sliceutil.Map(describeAppHTTPSListenersResp.ListenerList[0].AdditionalCertDomains, func(domain bceappblb.AdditionalCertDomainsModel) bceappblb.AdditionalCertDomainsModel {
if domain.Host == d.config.Domain { if domain.Host == d.config.Domain {
return bceappblb.AdditionalCertDomainsModel{ return bceappblb.AdditionalCertDomainsModel{

View File

@ -283,6 +283,7 @@ func (d *DeployerProvider) updateHttpsListenerCertificate(ctx context.Context, c
updateHTTPSListenerReq := &bceblb.UpdateHTTPSListenerArgs{ updateHTTPSListenerReq := &bceblb.UpdateHTTPSListenerArgs{
ClientToken: generateClientToken(), ClientToken: generateClientToken(),
ListenerPort: uint16(cloudHttpsListenerPort), ListenerPort: uint16(cloudHttpsListenerPort),
CertIds: describeHTTPSListenersResp.ListenerList[0].CertIds,
AdditionalCertDomains: sliceutil.Map(describeHTTPSListenersResp.ListenerList[0].AdditionalCertDomains, func(domain bceblb.AdditionalCertDomainsModel) bceblb.AdditionalCertDomainsModel { AdditionalCertDomains: sliceutil.Map(describeHTTPSListenersResp.ListenerList[0].AdditionalCertDomains, func(domain bceblb.AdditionalCertDomainsModel) bceblb.AdditionalCertDomainsModel {
if domain.Host == d.config.Domain { if domain.Host == d.config.Domain {
return bceblb.AdditionalCertDomainsModel{ return bceblb.AdditionalCertDomainsModel{

View File

@ -6,11 +6,13 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"strconv" "strconv"
"strings"
"github.com/usual2970/certimate/internal/pkg/core/deployer" "github.com/usual2970/certimate/internal/pkg/core/deployer"
"github.com/usual2970/certimate/internal/pkg/core/uploader" "github.com/usual2970/certimate/internal/pkg/core/uploader"
uploadersp "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/wangsu-certificate" uploadersp "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/wangsu-certificate"
wangsusdk "github.com/usual2970/certimate/internal/pkg/sdk3rd/wangsu/cdn" wangsusdk "github.com/usual2970/certimate/internal/pkg/sdk3rd/wangsu/cdn"
sliceutil "github.com/usual2970/certimate/internal/pkg/utils/slice"
) )
type DeployerConfig struct { type DeployerConfig struct {
@ -18,7 +20,7 @@ type DeployerConfig struct {
AccessKeyId string `json:"accessKeyId"` AccessKeyId string `json:"accessKeyId"`
// 网宿云 AccessKeySecret。 // 网宿云 AccessKeySecret。
AccessKeySecret string `json:"accessKeySecret"` AccessKeySecret string `json:"accessKeySecret"`
// 加速域名数组 // 加速域名数组(支持泛域名)
Domains []string `json:"domains"` Domains []string `json:"domains"`
} }
@ -80,7 +82,10 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE
certId, _ := strconv.ParseInt(upres.CertId, 10, 64) certId, _ := strconv.ParseInt(upres.CertId, 10, 64)
batchUpdateCertificateConfigReq := &wangsusdk.BatchUpdateCertificateConfigRequest{ batchUpdateCertificateConfigReq := &wangsusdk.BatchUpdateCertificateConfigRequest{
CertificateId: certId, CertificateId: certId,
DomainNames: d.config.Domains, DomainNames: sliceutil.Map(d.config.Domains, func(domain string) string {
// "*.example.com" → ".example.com",适配网宿云 CDN 要求的泛域名格式
return strings.TrimPrefix(domain, "*")
}),
} }
batchUpdateCertificateConfigResp, err := d.sdkClient.BatchUpdateCertificateConfig(batchUpdateCertificateConfigReq) batchUpdateCertificateConfigResp, err := d.sdkClient.BatchUpdateCertificateConfig(batchUpdateCertificateConfigReq)
d.logger.Debug("sdk request 'cdn.BatchUpdateCertificateConfig'", slog.Any("request", batchUpdateCertificateConfigReq), slog.Any("response", batchUpdateCertificateConfigResp)) d.logger.Debug("sdk request 'cdn.BatchUpdateCertificateConfig'", slog.Any("request", batchUpdateCertificateConfigReq), slog.Any("response", batchUpdateCertificateConfigResp))

View File

@ -77,25 +77,6 @@ func (r *CertificateRepository) GetByWorkflowNodeId(ctx context.Context, workflo
return r.castRecordToModel(records[0]) return r.castRecordToModel(records[0])
} }
func (r *CertificateRepository) GetByWorkflowRunId(ctx context.Context, workflowRunId string) (*domain.Certificate, error) {
records, err := app.GetApp().FindRecordsByFilter(
domain.CollectionNameCertificate,
"workflowRunId={:workflowRunId} && deleted=null",
"-created",
1, 0,
dbx.Params{"workflowRunId": workflowRunId},
)
if err != nil {
return nil, err
}
if len(records) == 0 {
return nil, domain.ErrRecordNotFound
}
return r.castRecordToModel(records[0])
}
func (r *CertificateRepository) Save(ctx context.Context, certificate *domain.Certificate) (*domain.Certificate, error) { func (r *CertificateRepository) Save(ctx context.Context, certificate *domain.Certificate) (*domain.Certificate, error) {
collection, err := app.GetApp().FindCollectionByNameOrId(domain.CollectionNameCertificate) collection, err := app.GetApp().FindCollectionByNameOrId(domain.CollectionNameCertificate)
if err != nil { if err != nil {

View File

@ -112,6 +112,7 @@ func (w *workflowInvoker) processNode(ctx context.Context, node *domain.Workflow
break break
} }
// TODO: 优化可读性 // TODO: 优化可读性
if procErr != nil && current.Type == domain.WorkflowNodeTypeCondition { if procErr != nil && current.Type == domain.WorkflowNodeTypeCondition {
current = nil current = nil

View File

@ -3,6 +3,7 @@ package nodeprocessor
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"strconv" "strconv"
"time" "time"
@ -35,7 +36,8 @@ func NewApplyNode(node *domain.WorkflowNode) *applyNode {
} }
func (n *applyNode) Process(ctx context.Context) error { func (n *applyNode) Process(ctx context.Context) error {
n.logger.Info("ready to obtain certificiate ...") nodeCfg := n.node.GetConfigForApply()
n.logger.Info("ready to obtain certificiate ...", slog.Any("config", nodeCfg))
// 查询上次执行结果 // 查询上次执行结果
lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id) lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id)
@ -45,6 +47,7 @@ func (n *applyNode) Process(ctx context.Context) error {
// 检测是否可以跳过本次执行 // 检测是否可以跳过本次执行
if skippable, reason := n.checkCanSkip(ctx, lastOutput); skippable { if skippable, reason := n.checkCanSkip(ctx, lastOutput); skippable {
n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(true)
n.logger.Info(fmt.Sprintf("skip this application, because %s", reason)) n.logger.Info(fmt.Sprintf("skip this application, because %s", reason))
return nil return nil
} else if reason != "" { } else if reason != "" {
@ -101,8 +104,8 @@ func (n *applyNode) Process(ctx context.Context) error {
} }
// 保存 ARI 记录 // 保存 ARI 记录
if applyResult.ARIReplaced { if applyResult.ARIReplaced && lastOutput != nil {
lastCertificate, _ := n.certRepo.GetByWorkflowRunId(ctx, lastOutput.RunId) lastCertificate, _ := n.certRepo.GetByWorkflowNodeId(ctx, lastOutput.NodeId)
if lastCertificate != nil { if lastCertificate != nil {
lastCertificate.ACMERenewed = true lastCertificate.ACMERenewed = true
n.certRepo.Save(ctx, lastCertificate) n.certRepo.Save(ctx, lastCertificate)
@ -110,6 +113,7 @@ func (n *applyNode) Process(ctx context.Context) error {
} }
// 记录中间结果 // 记录中间结果
n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(false)
n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(true) n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(true)
n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(time.Until(certificate.ExpireAt).Hours()/24), 10) n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(time.Until(certificate.ExpireAt).Hours()/24), 10)
@ -120,39 +124,40 @@ func (n *applyNode) Process(ctx context.Context) error {
func (n *applyNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (_skip bool, _reason string) { func (n *applyNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (_skip bool, _reason string) {
if lastOutput != nil && lastOutput.Succeeded { if lastOutput != nil && lastOutput.Succeeded {
// 比较和上次申请时的关键配置(即影响证书签发的)参数是否一致 // 比较和上次申请时的关键配置(即影响证书签发的)参数是否一致
currentNodeConfig := n.node.GetConfigForApply() thisNodeCfg := n.node.GetConfigForApply()
lastNodeConfig := lastOutput.Node.GetConfigForApply() lastNodeCfg := lastOutput.Node.GetConfigForApply()
if currentNodeConfig.Domains != lastNodeConfig.Domains {
if thisNodeCfg.Domains != lastNodeCfg.Domains {
return false, "the configuration item 'Domains' changed" return false, "the configuration item 'Domains' changed"
} }
if currentNodeConfig.ContactEmail != lastNodeConfig.ContactEmail { if thisNodeCfg.ContactEmail != lastNodeCfg.ContactEmail {
return false, "the configuration item 'ContactEmail' changed" return false, "the configuration item 'ContactEmail' changed"
} }
if currentNodeConfig.Provider != lastNodeConfig.Provider { if thisNodeCfg.Provider != lastNodeCfg.Provider {
return false, "the configuration item 'Provider' changed" return false, "the configuration item 'Provider' changed"
} }
if currentNodeConfig.ProviderAccessId != lastNodeConfig.ProviderAccessId { if thisNodeCfg.ProviderAccessId != lastNodeCfg.ProviderAccessId {
return false, "the configuration item 'ProviderAccessId' changed" return false, "the configuration item 'ProviderAccessId' changed"
} }
if !maps.Equal(currentNodeConfig.ProviderConfig, lastNodeConfig.ProviderConfig) { if !maps.Equal(thisNodeCfg.ProviderConfig, lastNodeCfg.ProviderConfig) {
return false, "the configuration item 'ProviderConfig' changed" return false, "the configuration item 'ProviderConfig' changed"
} }
if currentNodeConfig.CAProvider != lastNodeConfig.CAProvider { if thisNodeCfg.CAProvider != lastNodeCfg.CAProvider {
return false, "the configuration item 'CAProvider' changed" return false, "the configuration item 'CAProvider' changed"
} }
if currentNodeConfig.CAProviderAccessId != lastNodeConfig.CAProviderAccessId { if thisNodeCfg.CAProviderAccessId != lastNodeCfg.CAProviderAccessId {
return false, "the configuration item 'CAProviderAccessId' changed" return false, "the configuration item 'CAProviderAccessId' changed"
} }
if !maps.Equal(currentNodeConfig.CAProviderConfig, lastNodeConfig.CAProviderConfig) { if !maps.Equal(thisNodeCfg.CAProviderConfig, lastNodeCfg.CAProviderConfig) {
return false, "the configuration item 'CAProviderConfig' changed" return false, "the configuration item 'CAProviderConfig' changed"
} }
if currentNodeConfig.KeyAlgorithm != lastNodeConfig.KeyAlgorithm { if thisNodeCfg.KeyAlgorithm != lastNodeCfg.KeyAlgorithm {
return false, "the configuration item 'KeyAlgorithm' changed" return false, "the configuration item 'KeyAlgorithm' changed"
} }
lastCertificate, _ := n.certRepo.GetByWorkflowRunId(ctx, lastOutput.RunId) lastCertificate, _ := n.certRepo.GetByWorkflowNodeId(ctx, lastOutput.NodeId)
if lastCertificate != nil { if lastCertificate != nil {
renewalInterval := time.Duration(currentNodeConfig.SkipBeforeExpiryDays) * time.Hour * 24 renewalInterval := time.Duration(thisNodeCfg.SkipBeforeExpiryDays) * time.Hour * 24
expirationTime := time.Until(lastCertificate.ExpireAt) expirationTime := time.Until(lastCertificate.ExpireAt)
if expirationTime > renewalInterval { if expirationTime > renewalInterval {
daysLeft := int(expirationTime.Hours() / 24) daysLeft := int(expirationTime.Hours() / 24)
@ -160,7 +165,7 @@ func (n *applyNode) checkCanSkip(ctx context.Context, lastOutput *domain.Workflo
n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(true) n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(true)
n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(daysLeft), 10) n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(daysLeft), 10)
return true, fmt.Sprintf("the certificate has already been issued (expires in %d day(s), next renewal in %d day(s))", daysLeft, currentNodeConfig.SkipBeforeExpiryDays) return true, fmt.Sprintf("the certificate has already been issued (expires in %d day(s), next renewal in %d day(s))", daysLeft, thisNodeCfg.SkipBeforeExpiryDays)
} }
} }
} }

View File

@ -47,6 +47,6 @@ func (n *conditionNode) Process(ctx context.Context) error {
} }
func (n *conditionNode) evalExpr(ctx context.Context, expression expr.Expr) (*expr.EvalResult, error) { func (n *conditionNode) evalExpr(ctx context.Context, expression expr.Expr) (*expr.EvalResult, error) {
variables := GetNodeOutputs(ctx) variables := GetAllNodeOutputs(ctx)
return expression.Eval(variables) return expression.Eval(variables)
} }

View File

@ -3,4 +3,5 @@ package nodeprocessor
const ( const (
outputKeyForCertificateValidity = "certificate.validity" outputKeyForCertificateValidity = "certificate.validity"
outputKeyForCertificateDaysLeft = "certificate.daysLeft" outputKeyForCertificateDaysLeft = "certificate.daysLeft"
outputKeyForNodeSkipped = "node.skipped"
) )

View File

@ -25,6 +25,15 @@ func newNodeOutputsContainer() *nodeOutputsContainer {
} }
} }
// 获取节点输出容器
func getNodeOutputsContainer(ctx context.Context) *nodeOutputsContainer {
value := ctx.Value(nodeOutputsKey)
if value == nil {
return nil
}
return value.(*nodeOutputsContainer)
}
// 添加节点输出到上下文 // 添加节点输出到上下文
func AddNodeOutput(ctx context.Context, nodeId string, output map[string]any) context.Context { func AddNodeOutput(ctx context.Context, nodeId string, output map[string]any) context.Context {
container := getNodeOutputsContainer(ctx) container := getNodeOutputsContainer(ctx)
@ -50,7 +59,7 @@ func AddNodeOutput(ctx context.Context, nodeId string, output map[string]any) co
func GetNodeOutput(ctx context.Context, nodeId string) map[string]any { func GetNodeOutput(ctx context.Context, nodeId string) map[string]any {
container := getNodeOutputsContainer(ctx) container := getNodeOutputsContainer(ctx)
if container == nil { if container == nil {
return nil container = newNodeOutputsContainer()
} }
container.RLock() container.RLock()
@ -69,22 +78,11 @@ func GetNodeOutput(ctx context.Context, nodeId string) map[string]any {
return outputCopy return outputCopy
} }
// 获取特定节点的特定输出项
func GetNodeOutputValue(ctx context.Context, nodeId string, key string) (any, bool) {
output := GetNodeOutput(ctx, nodeId)
if output == nil {
return nil, false
}
value, exists := output[key]
return value, exists
}
// 获取所有节点输出 // 获取所有节点输出
func GetNodeOutputs(ctx context.Context) map[string]map[string]any { func GetAllNodeOutputs(ctx context.Context) map[string]map[string]any {
container := getNodeOutputsContainer(ctx) container := getNodeOutputsContainer(ctx)
if container == nil { if container == nil {
return nil container = newNodeOutputsContainer()
} }
container.RLock() container.RLock()
@ -103,26 +101,3 @@ func GetNodeOutputs(ctx context.Context) map[string]map[string]any {
return allOutputs return allOutputs
} }
// 获取节点输出容器
func getNodeOutputsContainer(ctx context.Context) *nodeOutputsContainer {
value := ctx.Value(nodeOutputsKey)
if value == nil {
return nil
}
return value.(*nodeOutputsContainer)
}
// 检查节点是否有输出
func HasNodeOutput(ctx context.Context, nodeId string) bool {
container := getNodeOutputsContainer(ctx)
if container == nil {
return false
}
container.RLock()
defer container.RUnlock()
_, exists := container.outputs[nodeId]
return exists
}

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"strconv"
"strings" "strings"
"github.com/usual2970/certimate/internal/deployer" "github.com/usual2970/certimate/internal/deployer"
@ -33,7 +34,8 @@ func NewDeployNode(node *domain.WorkflowNode) *deployNode {
} }
func (n *deployNode) Process(ctx context.Context) error { func (n *deployNode) Process(ctx context.Context) error {
n.logger.Info("ready to deploy certificate ...") nodeCfg := n.node.GetConfigForDeploy()
n.logger.Info("ready to deploy certificate ...", slog.Any("config", nodeCfg))
// 查询上次执行结果 // 查询上次执行结果
lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id) lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id)
@ -58,6 +60,7 @@ func (n *deployNode) Process(ctx context.Context) error {
// 检测是否可以跳过本次执行 // 检测是否可以跳过本次执行
if lastOutput != nil && certificate.CreatedAt.Before(lastOutput.UpdatedAt) { if lastOutput != nil && certificate.CreatedAt.Before(lastOutput.UpdatedAt) {
if skippable, reason := n.checkCanSkip(ctx, lastOutput); skippable { if skippable, reason := n.checkCanSkip(ctx, lastOutput); skippable {
n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(true)
n.logger.Info(fmt.Sprintf("skip this deployment, because %s", reason)) n.logger.Info(fmt.Sprintf("skip this deployment, because %s", reason))
return nil return nil
} else if reason != "" { } else if reason != "" {
@ -96,6 +99,9 @@ func (n *deployNode) Process(ctx context.Context) error {
return err return err
} }
// 记录中间结果
n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(false)
n.logger.Info("deployment completed") n.logger.Info("deployment completed")
return nil return nil
} }
@ -103,16 +109,17 @@ func (n *deployNode) Process(ctx context.Context) error {
func (n *deployNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (_skip bool, _reason string) { func (n *deployNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (_skip bool, _reason string) {
if lastOutput != nil && lastOutput.Succeeded { if lastOutput != nil && lastOutput.Succeeded {
// 比较和上次部署时的关键配置(即影响证书部署的)参数是否一致 // 比较和上次部署时的关键配置(即影响证书部署的)参数是否一致
currentNodeConfig := n.node.GetConfigForDeploy() thisNodeCfg := n.node.GetConfigForDeploy()
lastNodeConfig := lastOutput.Node.GetConfigForDeploy() lastNodeCfg := lastOutput.Node.GetConfigForDeploy()
if currentNodeConfig.ProviderAccessId != lastNodeConfig.ProviderAccessId {
if thisNodeCfg.ProviderAccessId != lastNodeCfg.ProviderAccessId {
return false, "the configuration item 'ProviderAccessId' changed" return false, "the configuration item 'ProviderAccessId' changed"
} }
if !maps.Equal(currentNodeConfig.ProviderConfig, lastNodeConfig.ProviderConfig) { if !maps.Equal(thisNodeCfg.ProviderConfig, lastNodeCfg.ProviderConfig) {
return false, "the configuration item 'ProviderConfig' changed" return false, "the configuration item 'ProviderConfig' changed"
} }
if currentNodeConfig.SkipOnLastSucceeded { if thisNodeCfg.SkipOnLastSucceeded {
return true, "the certificate has already been deployed" return true, "the certificate has already been deployed"
} }
} }

View File

@ -5,7 +5,9 @@ import (
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"fmt" "fmt"
"log/slog"
"math" "math"
"net"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@ -30,13 +32,12 @@ func NewMonitorNode(node *domain.WorkflowNode) *monitorNode {
} }
func (n *monitorNode) Process(ctx context.Context) error { func (n *monitorNode) Process(ctx context.Context) error {
n.logger.Info("ready to monitor certificate ...")
nodeCfg := n.node.GetConfigForMonitor() nodeCfg := n.node.GetConfigForMonitor()
n.logger.Info("ready to monitor certificate ...", slog.Any("config", nodeCfg))
targetAddr := fmt.Sprintf("%s:%d", nodeCfg.Host, nodeCfg.Port) targetAddr := net.JoinHostPort(nodeCfg.Host, fmt.Sprintf("%d", nodeCfg.Port))
if nodeCfg.Port == 0 { if nodeCfg.Port == 0 {
targetAddr = fmt.Sprintf("%s:443", nodeCfg.Host) targetAddr = net.JoinHostPort(nodeCfg.Host, "443")
} }
targetDomain := nodeCfg.Domain targetDomain := nodeCfg.Domain

View File

@ -2,7 +2,9 @@ package nodeprocessor
import ( import (
"context" "context"
"fmt"
"log/slog" "log/slog"
"strconv"
"github.com/usual2970/certimate/internal/domain" "github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/notify" "github.com/usual2970/certimate/internal/notify"
@ -28,9 +30,8 @@ func NewNotifyNode(node *domain.WorkflowNode) *notifyNode {
} }
func (n *notifyNode) Process(ctx context.Context) error { func (n *notifyNode) Process(ctx context.Context) error {
n.logger.Info("ready to send notification ...")
nodeCfg := n.node.GetConfigForNotify() nodeCfg := n.node.GetConfigForNotify()
n.logger.Info("ready to send notification ...", slog.Any("config", nodeCfg))
if nodeCfg.Provider == "" { if nodeCfg.Provider == "" {
// Deprecated: v0.4.x 将废弃 // Deprecated: v0.4.x 将废弃
@ -59,6 +60,12 @@ func (n *notifyNode) Process(ctx context.Context) error {
return nil return nil
} }
// 检测是否可以跳过本次执行
if skippable := n.checkCanSkip(ctx); skippable {
n.logger.Info(fmt.Sprintf("skip this notification, because all the previous nodes have been skipped"))
return nil
}
// 初始化通知器 // 初始化通知器
deployer, err := notify.NewWithWorkflowNode(notify.NotifierWithWorkflowNodeConfig{ deployer, err := notify.NewWithWorkflowNode(notify.NotifierWithWorkflowNodeConfig{
Node: n.node, Node: n.node,
@ -80,3 +87,21 @@ func (n *notifyNode) Process(ctx context.Context) error {
n.logger.Info("notification completed") n.logger.Info("notification completed")
return nil return nil
} }
func (n *notifyNode) checkCanSkip(ctx context.Context) (_skip bool) {
thisNodeCfg := n.node.GetConfigForNotify()
if !thisNodeCfg.SkipOnAllPrevSkipped {
return false
}
prevNodeOutputs := GetAllNodeOutputs(ctx)
for _, nodeOutput := range prevNodeOutputs {
if nodeOutput[outputKeyForNodeSkipped] != nil {
if nodeOutput[outputKeyForNodeSkipped].(string) != strconv.FormatBool(true) {
return false
}
}
}
return true
}

View File

@ -50,7 +50,6 @@ func (n *nodeOutputer) GetOutputs() map[string]any {
type certificateRepository interface { type certificateRepository interface {
GetByWorkflowNodeId(ctx context.Context, workflowNodeId string) (*domain.Certificate, error) GetByWorkflowNodeId(ctx context.Context, workflowNodeId string) (*domain.Certificate, error)
GetByWorkflowRunId(ctx context.Context, workflowRunId string) (*domain.Certificate, error)
Save(ctx context.Context, certificate *domain.Certificate) (*domain.Certificate, error) Save(ctx context.Context, certificate *domain.Certificate) (*domain.Certificate, error)
} }

View File

@ -3,6 +3,7 @@ package nodeprocessor
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -32,9 +33,8 @@ func NewUploadNode(node *domain.WorkflowNode) *uploadNode {
} }
func (n *uploadNode) Process(ctx context.Context) error { func (n *uploadNode) Process(ctx context.Context) error {
n.logger.Info("ready to upload certiticate ...")
nodeCfg := n.node.GetConfigForUpload() nodeCfg := n.node.GetConfigForUpload()
n.logger.Info("ready to upload certiticate ...", slog.Any("config", nodeCfg))
// 查询上次执行结果 // 查询上次执行结果
lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id) lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id)
@ -44,6 +44,7 @@ func (n *uploadNode) Process(ctx context.Context) error {
// 检测是否可以跳过本次执行 // 检测是否可以跳过本次执行
if skippable, reason := n.checkCanSkip(ctx, lastOutput); skippable { if skippable, reason := n.checkCanSkip(ctx, lastOutput); skippable {
n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(true)
n.logger.Info(fmt.Sprintf("skip this uploading, because %s", reason)) n.logger.Info(fmt.Sprintf("skip this uploading, because %s", reason))
return nil return nil
} else if reason != "" { } else if reason != "" {
@ -71,6 +72,7 @@ func (n *uploadNode) Process(ctx context.Context) error {
} }
// 记录中间结果 // 记录中间结果
n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(false)
n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(true) n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(true)
n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(time.Until(certificate.ExpireAt).Hours()/24), 10) n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(time.Until(certificate.ExpireAt).Hours()/24), 10)
@ -81,16 +83,17 @@ func (n *uploadNode) Process(ctx context.Context) error {
func (n *uploadNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (_skip bool, _reason string) { func (n *uploadNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (_skip bool, _reason string) {
if lastOutput != nil && lastOutput.Succeeded { if lastOutput != nil && lastOutput.Succeeded {
// 比较和上次上传时的关键配置(即影响证书上传的)参数是否一致 // 比较和上次上传时的关键配置(即影响证书上传的)参数是否一致
currentNodeConfig := n.node.GetConfigForUpload() thisNodeCfg := n.node.GetConfigForUpload()
lastNodeConfig := lastOutput.Node.GetConfigForUpload() lastNodeCfg := lastOutput.Node.GetConfigForUpload()
if strings.TrimSpace(currentNodeConfig.Certificate) != strings.TrimSpace(lastNodeConfig.Certificate) {
if strings.TrimSpace(thisNodeCfg.Certificate) != strings.TrimSpace(lastNodeCfg.Certificate) {
return false, "the configuration item 'Certificate' changed" return false, "the configuration item 'Certificate' changed"
} }
if strings.TrimSpace(currentNodeConfig.PrivateKey) != strings.TrimSpace(lastNodeConfig.PrivateKey) { if strings.TrimSpace(thisNodeCfg.PrivateKey) != strings.TrimSpace(lastNodeCfg.PrivateKey) {
return false, "the configuration item 'PrivateKey' changed" return false, "the configuration item 'PrivateKey' changed"
} }
lastCertificate, _ := n.certRepo.GetByWorkflowRunId(ctx, lastOutput.RunId) lastCertificate, _ := n.certRepo.GetByWorkflowNodeId(ctx, lastOutput.NodeId)
if lastCertificate != nil { if lastCertificate != nil {
daysLeft := int(time.Until(lastCertificate.ExpireAt).Hours() / 24) daysLeft := int(time.Until(lastCertificate.ExpireAt).Hours() / 24)
n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(daysLeft > 0) n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(daysLeft > 0)

View File

@ -0,0 +1,122 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useControllableValue } from "ahooks";
import { Modal, notification } from "antd";
import ModalForm from "@/components/ModalForm";
import { useTriggerElement, useZustandShallowSelector } from "@/hooks";
import { getErrMsg } from "@/utils/error";
import WorkflowForm, { type WorkflowFormInstance, type WorkflowFormProps } from "./WorkflowForm";
export type WorkflowEditModalProps = {
data?: WorkflowFormProps["initialValues"];
loading?: boolean;
open?: boolean;
usage?: WorkflowFormProps["usage"];
scene: WorkflowFormProps["scene"];
trigger?: React.ReactNode;
onOpenChange?: (open: boolean) => void;
afterSubmit?: (record: WorkflowModel) => void;
};
const WorkflowEditModal = ({ data, loading, trigger, scene, usage, afterSubmit, ...props }: WorkflowEditModalProps) => {
const { t } = useTranslation();
const [notificationApi, NotificationContextHolder] = notification.useNotification();
const { createWorkflow, updateWorkflow } = useWorkflowesStore(useZustandShallowSelector(["createWorkflow", "updateWorkflow"]));
const [open, setOpen] = useControllableValue<boolean>(props, {
valuePropName: "open",
defaultValuePropName: "defaultOpen",
trigger: "onOpenChange",
});
const triggerEl = useTriggerElement(trigger, { onClick: () => setOpen(true) });
const formRef = useRef<WorkflowFormInstance>(null);
const [formPending, setFormPending] = useState(false);
const handleOkClick = async () => {
setFormPending(true);
try {
await formRef.current!.validateFields();
} catch (err) {
setFormPending(false);
throw err;
}
try {
let values: WorkflowModel = formRef.current!.getFieldsValue();
if (scene === "add") {
if (data?.id) {
throw "Invalid props: `data`";
}
values = await createWorkflow(values);
} else if (scene === "edit") {
if (!data?.id) {
throw "Invalid props: `data`";
}
values = await updateWorkflow({ ...data, ...values });
} else {
throw "Invalid props: `preset`";
}
afterSubmit?.(values);
setOpen(false);
} catch (err) {
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
throw err;
} finally {
setFormPending(false);
}
};
const handleCancelClick = () => {
if (formPending) return;
setOpen(false);
};
return (
<>
{NotificationContextHolder}
{triggerEl}
<Modal
styles={{
content: {
maxHeight: "calc(80vh - 64px)",
overflowX: "hidden",
overflowY: "auto",
},
}}
afterClose={() => setOpen(false)}
cancelButtonProps={{ disabled: formPending }}
cancelText={t("common.button.cancel")}
closable
confirmLoading={formPending}
destroyOnHidden
loading={loading}
okText={scene === "edit" ? t("common.button.save") : t("common.button.submit")}
open={open}
title={t(`access.action.${scene}`)}
width={480}
onOk={handleOkClick}
onCancel={handleCancelClick}
>
<div className="pb-2 pt-4">
<WorkflowForm ref={formRef} initialValues={data} scene={scene === "add" ? "add" : "edit"} usage={usage} />
</div>
</Modal>
</>
);
};
export default WorkflowEditModal;

View File

@ -28,7 +28,7 @@ import ACMEDns01ProviderSelect from "@/components/provider/ACMEDns01ProviderSele
import CAProviderSelect from "@/components/provider/CAProviderSelect"; import CAProviderSelect from "@/components/provider/CAProviderSelect";
import Show from "@/components/Show"; import Show from "@/components/Show";
import { ACCESS_USAGES, ACME_DNS01_PROVIDERS, accessProvidersMap, acmeDns01ProvidersMap, caProvidersMap } from "@/domain/provider"; import { ACCESS_USAGES, ACME_DNS01_PROVIDERS, accessProvidersMap, acmeDns01ProvidersMap, caProvidersMap } from "@/domain/provider";
import { type WorkflowNodeConfigForApply } from "@/domain/workflow"; import { type WorkflowNodeConfigForApply, defaultNodeConfigForApply } from "@/domain/workflow";
import { useAntdForm, useAntdFormName, useZustandShallowSelector } from "@/hooks"; import { useAntdForm, useAntdFormName, useZustandShallowSelector } from "@/hooks";
import { useAccessesStore } from "@/stores/access"; import { useAccessesStore } from "@/stores/access";
import { useContactEmailsStore } from "@/stores/contact"; import { useContactEmailsStore } from "@/stores/contact";
@ -59,11 +59,7 @@ export type ApplyNodeConfigFormInstance = {
const MULTIPLE_INPUT_SEPARATOR = ";"; const MULTIPLE_INPUT_SEPARATOR = ";";
const initFormModel = (): ApplyNodeConfigFormFieldValues => { const initFormModel = (): ApplyNodeConfigFormFieldValues => {
return { return defaultNodeConfigForApply();
challengeType: "dns-01",
keyAlgorithm: "RSA2048",
skipBeforeExpiryDays: 30,
};
}; };
const ApplyNodeConfigForm = forwardRef<ApplyNodeConfigFormInstance, ApplyNodeConfigFormProps>( const ApplyNodeConfigForm = forwardRef<ApplyNodeConfigFormInstance, ApplyNodeConfigFormProps>(

View File

@ -4,7 +4,7 @@ import { Form, type FormInstance } from "antd";
import { createSchemaFieldRule } from "antd-zod"; import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod"; import { z } from "zod";
import { type Expr, type WorkflowNodeConfigForCondition } from "@/domain/workflow"; import { type Expr, type WorkflowNodeConfigForCondition, defaultNodeConfigForCondition } from "@/domain/workflow";
import { useAntdForm } from "@/hooks"; import { useAntdForm } from "@/hooks";
import ConditionNodeConfigFormExpressionEditor, { type ConditionNodeConfigFormExpressionEditorInstance } from "./ConditionNodeConfigFormExpressionEditor"; import ConditionNodeConfigFormExpressionEditor, { type ConditionNodeConfigFormExpressionEditorInstance } from "./ConditionNodeConfigFormExpressionEditor";
@ -29,7 +29,7 @@ export type ConditionNodeConfigFormInstance = {
}; };
const initFormModel = (): ConditionNodeConfigFormFieldValues => { const initFormModel = (): ConditionNodeConfigFormFieldValues => {
return {}; return defaultNodeConfigForCondition();
}; };
const ConditionNodeConfigForm = forwardRef<ConditionNodeConfigFormInstance, ConditionNodeConfigFormProps>( const ConditionNodeConfigForm = forwardRef<ConditionNodeConfigFormInstance, ConditionNodeConfigFormProps>(

View File

@ -11,7 +11,7 @@ import DeploymentProviderPicker from "@/components/provider/DeploymentProviderPi
import DeploymentProviderSelect from "@/components/provider/DeploymentProviderSelect.tsx"; import DeploymentProviderSelect from "@/components/provider/DeploymentProviderSelect.tsx";
import Show from "@/components/Show"; import Show from "@/components/Show";
import { ACCESS_USAGES, DEPLOYMENT_PROVIDERS, accessProvidersMap, deploymentProvidersMap } from "@/domain/provider"; import { ACCESS_USAGES, DEPLOYMENT_PROVIDERS, accessProvidersMap, deploymentProvidersMap } from "@/domain/provider";
import { type WorkflowNodeConfigForDeploy, WorkflowNodeType } from "@/domain/workflow"; import { type WorkflowNodeConfigForDeploy, WorkflowNodeType, defaultNodeConfigForDeploy } from "@/domain/workflow";
import { useAntdForm, useAntdFormName, useZustandShallowSelector } from "@/hooks"; import { useAntdForm, useAntdFormName, useZustandShallowSelector } from "@/hooks";
import { useWorkflowStore } from "@/stores/workflow"; import { useWorkflowStore } from "@/stores/workflow";
@ -117,9 +117,7 @@ export type DeployNodeConfigFormInstance = {
}; };
const initFormModel = (): DeployNodeConfigFormFieldValues => { const initFormModel = (): DeployNodeConfigFormFieldValues => {
return { return defaultNodeConfigForDeploy();
skipOnLastSucceeded: true,
};
}; };
const DeployNodeConfigForm = forwardRef<DeployNodeConfigFormInstance, DeployNodeConfigFormProps>( const DeployNodeConfigForm = forwardRef<DeployNodeConfigFormInstance, DeployNodeConfigFormProps>(

View File

@ -43,7 +43,7 @@ const DeployNodeConfigFormWangsuCDNConfig = ({
if (!v) return false; if (!v) return false;
return String(v) return String(v)
.split(MULTIPLE_INPUT_SEPARATOR) .split(MULTIPLE_INPUT_SEPARATOR)
.every((e) => validDomainName(e)); .every((e) => validDomainName(e, { allowWildcard: true }));
}, t("workflow_node.deploy.form.wangsu_cdn_domains.placeholder")), }, t("workflow_node.deploy.form.wangsu_cdn_domains.placeholder")),
}); });
const formRule = createSchemaFieldRule(formSchema); const formRule = createSchemaFieldRule(formSchema);

View File

@ -4,7 +4,7 @@ import { Alert, Form, type FormInstance, Input, InputNumber } from "antd";
import { createSchemaFieldRule } from "antd-zod"; import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod"; import { z } from "zod";
import { type WorkflowNodeConfigForMonitor } from "@/domain/workflow"; import { type WorkflowNodeConfigForMonitor, defaultNodeConfigForMonitor } from "@/domain/workflow";
import { useAntdForm } from "@/hooks"; import { useAntdForm } from "@/hooks";
import { validDomainName, validIPv4Address, validIPv6Address, validPortNumber } from "@/utils/validators"; import { validDomainName, validIPv4Address, validIPv6Address, validPortNumber } from "@/utils/validators";
@ -25,11 +25,7 @@ export type MonitorNodeConfigFormInstance = {
}; };
const initFormModel = (): MonitorNodeConfigFormFieldValues => { const initFormModel = (): MonitorNodeConfigFormFieldValues => {
return { return defaultNodeConfigForMonitor();
host: "",
port: 443,
requestPath: "/",
};
}; };
const MonitorNodeConfigForm = forwardRef<MonitorNodeConfigFormInstance, MonitorNodeConfigFormProps>( const MonitorNodeConfigForm = forwardRef<MonitorNodeConfigFormInstance, MonitorNodeConfigFormProps>(

View File

@ -2,7 +2,7 @@ import { forwardRef, memo, useEffect, useImperativeHandle, useMemo, useState } f
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { PlusOutlined as PlusOutlinedIcon, RightOutlined as RightOutlinedIcon } from "@ant-design/icons"; import { PlusOutlined as PlusOutlinedIcon, RightOutlined as RightOutlinedIcon } from "@ant-design/icons";
import { Button, Divider, Form, type FormInstance, Input, Select, Typography } from "antd"; import { Button, Divider, Flex, Form, type FormInstance, Input, Select, Switch, Typography } from "antd";
import { createSchemaFieldRule } from "antd-zod"; import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod"; import { z } from "zod";
@ -12,7 +12,7 @@ import NotificationProviderSelect from "@/components/provider/NotificationProvid
import Show from "@/components/Show"; import Show from "@/components/Show";
import { ACCESS_USAGES, NOTIFICATION_PROVIDERS, accessProvidersMap, notificationProvidersMap } from "@/domain/provider"; import { ACCESS_USAGES, NOTIFICATION_PROVIDERS, accessProvidersMap, notificationProvidersMap } from "@/domain/provider";
import { notifyChannelsMap } from "@/domain/settings"; import { notifyChannelsMap } from "@/domain/settings";
import { type WorkflowNodeConfigForNotify } from "@/domain/workflow"; import { type WorkflowNodeConfigForNotify, defaultNodeConfigForNotify } from "@/domain/workflow";
import { useAntdForm, useAntdFormName, useZustandShallowSelector } from "@/hooks"; import { useAntdForm, useAntdFormName, useZustandShallowSelector } from "@/hooks";
import { useAccessesStore } from "@/stores/access"; import { useAccessesStore } from "@/stores/access";
import { useNotifyChannelsStore } from "@/stores/notify"; import { useNotifyChannelsStore } from "@/stores/notify";
@ -41,7 +41,7 @@ export type NotifyNodeConfigFormInstance = {
}; };
const initFormModel = (): NotifyNodeConfigFormFieldValues => { const initFormModel = (): NotifyNodeConfigFormFieldValues => {
return {}; return defaultNodeConfigForNotify();
}; };
const NotifyNodeConfigForm = forwardRef<NotifyNodeConfigFormInstance, NotifyNodeConfigFormProps>( const NotifyNodeConfigForm = forwardRef<NotifyNodeConfigFormInstance, NotifyNodeConfigFormProps>(
@ -74,6 +74,7 @@ const NotifyNodeConfigForm = forwardRef<NotifyNodeConfigFormInstance, NotifyNode
.string({ message: t("workflow_node.notify.form.provider_access.placeholder") }) .string({ message: t("workflow_node.notify.form.provider_access.placeholder") })
.nonempty(t("workflow_node.notify.form.provider_access.placeholder")), .nonempty(t("workflow_node.notify.form.provider_access.placeholder")),
providerConfig: z.any().nullish(), providerConfig: z.any().nullish(),
skipOnAllPrevSkipped: z.boolean().nullish(),
}); });
const formRule = createSchemaFieldRule(formSchema); const formRule = createSchemaFieldRule(formSchema);
const { form: formInst, formProps } = useAntdForm({ const { form: formInst, formProps } = useAntdForm({
@ -281,6 +282,27 @@ const NotifyNodeConfigForm = forwardRef<NotifyNodeConfigFormInstance, NotifyNode
{nestedFormEl} {nestedFormEl}
</Show> </Show>
<Divider size="small">
<Typography.Text className="text-xs font-normal" type="secondary">
{t("workflow_node.notify.form.strategy_config.label")}
</Typography.Text>
</Divider>
<Form className={className} style={style} {...formProps} disabled={disabled} layout="vertical" scrollToFirstError onValuesChange={handleFormChange}>
<Form.Item label={t("workflow_node.notify.form.skip_on_all_prev_skipped.label")}>
<Flex align="center" gap={8} wrap="wrap">
<div>{t("workflow_node.notify.form.skip_on_all_prev_skipped.prefix")}</div>
<Form.Item name="skipOnAllPrevSkipped" noStyle rules={[formRule]}>
<Switch
checkedChildren={t("workflow_node.notify.form.skip_on_all_prev_skipped.switch.on")}
unCheckedChildren={t("workflow_node.notify.form.skip_on_all_prev_skipped.switch.off")}
/>
</Form.Item>
<div>{t("workflow_node.notify.form.skip_on_all_prev_skipped.suffix")}</div>
</Flex>
</Form.Item>
</Form>
</Form> </Form>
); );
} }

View File

@ -6,7 +6,7 @@ import dayjs from "dayjs";
import { z } from "zod"; import { z } from "zod";
import Show from "@/components/Show"; import Show from "@/components/Show";
import { WORKFLOW_TRIGGERS, type WorkflowNodeConfigForStart, type WorkflowTriggerType } from "@/domain/workflow"; import { WORKFLOW_TRIGGERS, type WorkflowNodeConfigForStart, type WorkflowTriggerType, defaultNodeConfigForStart } from "@/domain/workflow";
import { useAntdForm } from "@/hooks"; import { useAntdForm } from "@/hooks";
import { getNextCronExecutions, validCronExpression } from "@/utils/cron"; import { getNextCronExecutions, validCronExpression } from "@/utils/cron";
@ -27,10 +27,7 @@ export type StartNodeConfigFormInstance = {
}; };
const initFormModel = (): StartNodeConfigFormFieldValues => { const initFormModel = (): StartNodeConfigFormFieldValues => {
return { return defaultNodeConfigForStart();
trigger: WORKFLOW_TRIGGERS.AUTO,
triggerCron: "0 0 * * *",
};
}; };
const StartNodeConfigForm = forwardRef<StartNodeConfigFormInstance, StartNodeConfigFormProps>( const StartNodeConfigForm = forwardRef<StartNodeConfigFormInstance, StartNodeConfigFormProps>(

View File

@ -6,7 +6,7 @@ import { z } from "zod";
import { validateCertificate, validatePrivateKey } from "@/api/certificates"; import { validateCertificate, validatePrivateKey } from "@/api/certificates";
import TextFileInput from "@/components/TextFileInput"; import TextFileInput from "@/components/TextFileInput";
import { type WorkflowNodeConfigForUpload } from "@/domain/workflow"; import { type WorkflowNodeConfigForUpload, defaultNodeConfigForUpload } from "@/domain/workflow";
import { useAntdForm } from "@/hooks"; import { useAntdForm } from "@/hooks";
import { getErrMsg } from "@/utils/error"; import { getErrMsg } from "@/utils/error";
@ -27,7 +27,7 @@ export type UploadNodeConfigFormInstance = {
}; };
const initFormModel = (): UploadNodeConfigFormFieldValues => { const initFormModel = (): UploadNodeConfigFormFieldValues => {
return {}; return defaultNodeConfigForUpload();
}; };
const UploadNodeConfigForm = forwardRef<UploadNodeConfigFormInstance, UploadNodeConfigFormProps>( const UploadNodeConfigForm = forwardRef<UploadNodeConfigFormInstance, UploadNodeConfigFormProps>(

View File

@ -133,6 +133,13 @@ export type WorkflowNodeConfigForStart = {
triggerCron?: string; triggerCron?: string;
}; };
export const defaultNodeConfigForStart = (): Partial<WorkflowNodeConfigForStart> => {
return {
trigger: WORKFLOW_TRIGGERS.AUTO,
triggerCron: "0 0 * * *",
};
};
export type WorkflowNodeConfigForApply = { export type WorkflowNodeConfigForApply = {
domains: string; domains: string;
contactEmail: string; contactEmail: string;
@ -152,6 +159,14 @@ export type WorkflowNodeConfigForApply = {
skipBeforeExpiryDays: number; skipBeforeExpiryDays: number;
}; };
export const defaultNodeConfigForApply = (): Partial<WorkflowNodeConfigForApply> => {
return {
challengeType: "dns-01",
keyAlgorithm: "RSA2048",
skipBeforeExpiryDays: 30,
};
};
export type WorkflowNodeConfigForUpload = { export type WorkflowNodeConfigForUpload = {
certificateId: string; certificateId: string;
domains: string; domains: string;
@ -159,6 +174,10 @@ export type WorkflowNodeConfigForUpload = {
privateKey: string; privateKey: string;
}; };
export const defaultNodeConfigForUpload = (): Partial<WorkflowNodeConfigForUpload> => {
return {};
};
export type WorkflowNodeConfigForMonitor = { export type WorkflowNodeConfigForMonitor = {
host: string; host: string;
port: number; port: number;
@ -166,6 +185,13 @@ export type WorkflowNodeConfigForMonitor = {
requestPath?: string; requestPath?: string;
}; };
export const defaultNodeConfigForMonitor = (): Partial<WorkflowNodeConfigForMonitor> => {
return {
port: 443,
requestPath: "/",
};
};
export type WorkflowNodeConfigForDeploy = { export type WorkflowNodeConfigForDeploy = {
certificate: string; certificate: string;
provider: string; provider: string;
@ -174,6 +200,12 @@ export type WorkflowNodeConfigForDeploy = {
skipOnLastSucceeded: boolean; skipOnLastSucceeded: boolean;
}; };
export const defaultNodeConfigForDeploy = (): Partial<WorkflowNodeConfigForDeploy> => {
return {
skipOnLastSucceeded: true,
};
};
export type WorkflowNodeConfigForNotify = { export type WorkflowNodeConfigForNotify = {
subject: string; subject: string;
message: string; message: string;
@ -184,12 +216,21 @@ export type WorkflowNodeConfigForNotify = {
provider: string; provider: string;
providerAccessId: string; providerAccessId: string;
providerConfig?: Record<string, unknown>; providerConfig?: Record<string, unknown>;
skipOnAllPrevSkipped?: boolean;
};
export const defaultNodeConfigForNotify = (): Partial<WorkflowNodeConfigForNotify> => {
return {};
}; };
export type WorkflowNodeConfigForCondition = { export type WorkflowNodeConfigForCondition = {
expression?: Expr; expression?: Expr;
}; };
export const defaultNodeConfigForCondition = (): Partial<WorkflowNodeConfigForCondition> => {
return {};
};
export type WorkflowNodeConfigForBranch = never; export type WorkflowNodeConfigForBranch = never;
export type WorkflowNodeConfigForEnd = never; export type WorkflowNodeConfigForEnd = never;
@ -243,15 +284,18 @@ type InitWorkflowOptions = {
}; };
export const initWorkflow = (options: InitWorkflowOptions = {}): WorkflowModel => { export const initWorkflow = (options: InitWorkflowOptions = {}): WorkflowModel => {
const root = newNode(WorkflowNodeType.Start, {}) as WorkflowNode; const root = newNode(WorkflowNodeType.Start, {
root.config = { trigger: WORKFLOW_TRIGGERS.MANUAL }; nodeConfig: { trigger: WORKFLOW_TRIGGERS.MANUAL },
});
switch (options.template) { switch (options.template) {
case "standard": case "standard":
{ {
let current = root; let current = root;
const applyNode = newNode(WorkflowNodeType.Apply); const applyNode = newNode(WorkflowNodeType.Apply, {
nodeConfig: defaultNodeConfigForApply(),
});
current.next = applyNode; current.next = applyNode;
current = current.next; current = current.next;
@ -260,6 +304,7 @@ export const initWorkflow = (options: InitWorkflowOptions = {}): WorkflowModel =
current = current.next!.branches![1]; current = current.next!.branches![1];
current.next = newNode(WorkflowNodeType.Notify, { current.next = newNode(WorkflowNodeType.Notify, {
nodeConfig: { nodeConfig: {
...defaultNodeConfigForNotify(),
subject: "[Certimate] Workflow Failure Alert!", subject: "[Certimate] Workflow Failure Alert!",
message: "Your workflow run for the certificate application has failed. Please check the details.", message: "Your workflow run for the certificate application has failed. Please check the details.",
} as WorkflowNodeConfigForNotify, } as WorkflowNodeConfigForNotify,
@ -268,8 +313,8 @@ export const initWorkflow = (options: InitWorkflowOptions = {}): WorkflowModel =
current = applyNode.next!.branches![0]; current = applyNode.next!.branches![0];
current.next = newNode(WorkflowNodeType.Deploy, { current.next = newNode(WorkflowNodeType.Deploy, {
nodeConfig: { nodeConfig: {
...defaultNodeConfigForDeploy(),
certificate: `${applyNode.id}#certificate`, certificate: `${applyNode.id}#certificate`,
skipOnLastSucceeded: true,
} as WorkflowNodeConfigForDeploy, } as WorkflowNodeConfigForDeploy,
}); });
@ -279,6 +324,7 @@ export const initWorkflow = (options: InitWorkflowOptions = {}): WorkflowModel =
current = current.next!.branches![1]; current = current.next!.branches![1];
current.next = newNode(WorkflowNodeType.Notify, { current.next = newNode(WorkflowNodeType.Notify, {
nodeConfig: { nodeConfig: {
...defaultNodeConfigForNotify(),
subject: "[Certimate] Workflow Failure Alert!", subject: "[Certimate] Workflow Failure Alert!",
message: "Your workflow run for the certificate deployment has failed. Please check the details.", message: "Your workflow run for the certificate deployment has failed. Please check the details.",
} as WorkflowNodeConfigForNotify, } as WorkflowNodeConfigForNotify,
@ -290,7 +336,9 @@ export const initWorkflow = (options: InitWorkflowOptions = {}): WorkflowModel =
{ {
let current = root; let current = root;
const monitorNode = newNode(WorkflowNodeType.Monitor); const monitorNode = newNode(WorkflowNodeType.Monitor, {
nodeConfig: defaultNodeConfigForMonitor(),
});
current.next = monitorNode; current.next = monitorNode;
current = current.next; current = current.next;
@ -299,6 +347,7 @@ export const initWorkflow = (options: InitWorkflowOptions = {}): WorkflowModel =
current = current.next!.branches![1]; current = current.next!.branches![1];
current.next = newNode(WorkflowNodeType.Notify, { current.next = newNode(WorkflowNodeType.Notify, {
nodeConfig: { nodeConfig: {
...defaultNodeConfigForNotify(),
subject: "[Certimate] Workflow Failure Alert!", subject: "[Certimate] Workflow Failure Alert!",
message: "Your workflow run for the certificate monitoring has failed. Please check the details.", message: "Your workflow run for the certificate monitoring has failed. Please check the details.",
} as WorkflowNodeConfigForNotify, } as WorkflowNodeConfigForNotify,
@ -352,6 +401,7 @@ export const initWorkflow = (options: InitWorkflowOptions = {}): WorkflowModel =
} as WorkflowNodeConfigForCondition; } as WorkflowNodeConfigForCondition;
current.next = newNode(WorkflowNodeType.Notify, { current.next = newNode(WorkflowNodeType.Notify, {
nodeConfig: { nodeConfig: {
...defaultNodeConfigForNotify(),
subject: "[Certimate] Certificate Expiry Alert!", subject: "[Certimate] Certificate Expiry Alert!",
message: "The certificate will expire soon. Please pay attention to your website.", message: "The certificate will expire soon. Please pay attention to your website.",
} as WorkflowNodeConfigForNotify, } as WorkflowNodeConfigForNotify,
@ -380,6 +430,7 @@ export const initWorkflow = (options: InitWorkflowOptions = {}): WorkflowModel =
} as WorkflowNodeConfigForCondition; } as WorkflowNodeConfigForCondition;
current.next = newNode(WorkflowNodeType.Notify, { current.next = newNode(WorkflowNodeType.Notify, {
nodeConfig: { nodeConfig: {
...defaultNodeConfigForNotify(),
subject: "[Certimate] Certificate Expiry Alert!", subject: "[Certimate] Certificate Expiry Alert!",
message: "The certificate has already expired. Please pay attention to your website.", message: "The certificate has already expired. Please pay attention to your website.",
} as WorkflowNodeConfigForNotify, } as WorkflowNodeConfigForNotify,
@ -458,18 +509,22 @@ export const newNode = (nodeType: WorkflowNodeType, options: NewNodeOptions = {}
return node; return node;
}; };
export const cloneNode = (sourceNode: WorkflowNode): WorkflowNode => { type CloneNodeOptions = {
withCopySuffix?: boolean;
};
export const cloneNode = (sourceNode: WorkflowNode, { withCopySuffix }: CloneNodeOptions = { withCopySuffix: true }): WorkflowNode => {
const { produce } = new Immer({ autoFreeze: false }); const { produce } = new Immer({ autoFreeze: false });
const deepClone = (node: WorkflowNode): WorkflowNode => { const deepClone = (node: WorkflowNode): WorkflowNode => {
return produce(node, (draft) => { return produce(node, (draft) => {
draft.id = nanoid(); draft.id = nanoid();
if (draft.next) { if (draft.next) {
draft.next = cloneNode(draft.next); draft.next = cloneNode(draft.next, { withCopySuffix });
} }
if (draft.branches) { if (draft.branches) {
draft.branches = draft.branches.map((branch) => cloneNode(branch)); draft.branches = draft.branches.map((branch) => cloneNode(branch, { withCopySuffix }));
} }
return draft; return draft;
@ -477,7 +532,7 @@ export const cloneNode = (sourceNode: WorkflowNode): WorkflowNode => {
}; };
const copyNode = produce(sourceNode, (draft) => { const copyNode = produce(sourceNode, (draft) => {
draft.name = `${draft.name}-copy`; draft.name = withCopySuffix ? `${draft.name}-copy` : draft.name;
}); });
return deepClone(copyNode); return deepClone(copyNode);
}; };

View File

@ -7,6 +7,8 @@
"workflow.action.create": "Create workflow", "workflow.action.create": "Create workflow",
"workflow.action.edit": "Edit workflow", "workflow.action.edit": "Edit workflow",
"workflow.action.duplicate": "Duplicate workflow",
"workflow.action.duplicate.confirm": "Are you sure to duplicate this workflow?",
"workflow.action.delete": "Delete workflow", "workflow.action.delete": "Delete workflow",
"workflow.action.delete.confirm": "Are you sure to delete this workflow?", "workflow.action.delete.confirm": "Are you sure to delete this workflow?",
"workflow.action.enable": "Enable", "workflow.action.enable": "Enable",

View File

@ -878,6 +878,12 @@
"workflow_node.notify.form.webhook_data.tooltip": "Leave it blank to use the default Webhook data provided by the authorization.", "workflow_node.notify.form.webhook_data.tooltip": "Leave it blank to use the default Webhook data provided by the authorization.",
"workflow_node.notify.form.webhook_data.guide": "<details><summary>Supported variables: </summary><ol style=\"margin-left: 1.25em; list-style: disc;\"><li><strong>${SUBJECT}</strong>: The subject of notification.</li><li><strong>${MESSAGE}</strong>: The message of notification.</li></ol></details><br>Please visit the authorization management page for addtional notes.", "workflow_node.notify.form.webhook_data.guide": "<details><summary>Supported variables: </summary><ol style=\"margin-left: 1.25em; list-style: disc;\"><li><strong>${SUBJECT}</strong>: The subject of notification.</li><li><strong>${MESSAGE}</strong>: The message of notification.</li></ol></details><br>Please visit the authorization management page for addtional notes.",
"workflow_node.notify.form.webhook_data.errmsg.json_invalid": "Please enter a valiod JSON string", "workflow_node.notify.form.webhook_data.errmsg.json_invalid": "Please enter a valiod JSON string",
"workflow_node.notify.form.strategy_config.label": "Strategy settings",
"workflow_node.notify.form.skip_on_all_prev_skipped.label": "Silent behavior",
"workflow_node.notify.form.skip_on_all_prev_skipped.prefix": "If all the previous nodes were skipped, ",
"workflow_node.notify.form.skip_on_all_prev_skipped.suffix": " to notify.",
"workflow_node.notify.form.skip_on_all_prev_skipped.switch.on": "skip",
"workflow_node.notify.form.skip_on_all_prev_skipped.switch.off": "not skip",
"workflow_node.end.label": "End", "workflow_node.end.label": "End",
"workflow_node.end.default_name": "End", "workflow_node.end.default_name": "End",

View File

@ -7,6 +7,8 @@
"workflow.action.create": "新建工作流", "workflow.action.create": "新建工作流",
"workflow.action.edit": "编辑工作流", "workflow.action.edit": "编辑工作流",
"workflow.action.duplicate": "复制工作流",
"workflow.action.duplicate.confirm": "确定要复制此工作流吗?",
"workflow.action.delete": "删除工作流", "workflow.action.delete": "删除工作流",
"workflow.action.delete.confirm": "确定要删除此工作流吗?", "workflow.action.delete.confirm": "确定要删除此工作流吗?",
"workflow.action.enable": "启用", "workflow.action.enable": "启用",

View File

@ -795,7 +795,7 @@
"workflow_node.deploy.form.volcengine_tos_domain.placeholder": "请输入火山引擎 TOS 自定义域名", "workflow_node.deploy.form.volcengine_tos_domain.placeholder": "请输入火山引擎 TOS 自定义域名",
"workflow_node.deploy.form.volcengine_tos_domain.tooltip": "这是什么?请参阅 see <a href=\"https://console.volcengine.com/tos\" target=\"_blank\">https://console.volcengine.com/tos</a>", "workflow_node.deploy.form.volcengine_tos_domain.tooltip": "这是什么?请参阅 see <a href=\"https://console.volcengine.com/tos\" target=\"_blank\">https://console.volcengine.com/tos</a>",
"workflow_node.deploy.form.wangsu_cdn_domains.label": "网宿云 CDN 加速域名", "workflow_node.deploy.form.wangsu_cdn_domains.label": "网宿云 CDN 加速域名",
"workflow_node.deploy.form.wangsu_cdn_domains.placeholder": "请输入网宿云 CDN 加速域名(多个值请用半角分号隔开)", "workflow_node.deploy.form.wangsu_cdn_domains.placeholder": "请输入网宿云 CDN 加速域名(支持泛域名;多个值请用半角分号隔开)",
"workflow_node.deploy.form.wangsu_cdn_domains.tooltip": "这是什么?请参阅 <a href=\"https://cdn.console.wangsu.com/v2/index/#/property/list\" target=\"_blank\">https://cdn.console.wangsu.com/v2/index/#/property/list</a>", "workflow_node.deploy.form.wangsu_cdn_domains.tooltip": "这是什么?请参阅 <a href=\"https://cdn.console.wangsu.com/v2/index/#/property/list\" target=\"_blank\">https://cdn.console.wangsu.com/v2/index/#/property/list</a>",
"workflow_node.deploy.form.wangsu_cdn_domains.multiple_input_modal.title": "修改网宿云 CDN 加速域名", "workflow_node.deploy.form.wangsu_cdn_domains.multiple_input_modal.title": "修改网宿云 CDN 加速域名",
"workflow_node.deploy.form.wangsu_cdn_domains.multiple_input_modal.placeholder": "请输入网宿云 CDN 加速域名", "workflow_node.deploy.form.wangsu_cdn_domains.multiple_input_modal.placeholder": "请输入网宿云 CDN 加速域名",
@ -845,7 +845,7 @@
"workflow_node.notify.form.subject.placeholder": "请输入通知主题", "workflow_node.notify.form.subject.placeholder": "请输入通知主题",
"workflow_node.notify.form.message.label": "通知内容", "workflow_node.notify.form.message.label": "通知内容",
"workflow_node.notify.form.message.placeholder": "请输入通知内容", "workflow_node.notify.form.message.placeholder": "请输入通知内容",
"workflow_node.notify.form.channel.label": "通知渠道(废弃,请使用「通知渠道授权」字段)", "workflow_node.notify.form.channel.label": "通知渠道(即将废弃,请使用「通知渠道授权」字段)",
"workflow_node.notify.form.channel.placeholder": "请选择通知渠道", "workflow_node.notify.form.channel.placeholder": "请选择通知渠道",
"workflow_node.notify.form.channel.button": "设置", "workflow_node.notify.form.channel.button": "设置",
"workflow_node.notify.form.provider.label": "通知渠道", "workflow_node.notify.form.provider.label": "通知渠道",
@ -877,6 +877,12 @@
"workflow_node.notify.form.webhook_data.tooltip": "不填写时,将使用所选部署目标授权的默认 Webhook 回调数据。", "workflow_node.notify.form.webhook_data.tooltip": "不填写时,将使用所选部署目标授权的默认 Webhook 回调数据。",
"workflow_node.notify.form.webhook_data.guide": "<details><summary>支持的变量:</summary><ol style=\"margin-left: 1.25em; list-style: disc;\"><li><strong>${SUBJECT}</strong>:通知主题。</li><li><strong>${MESSAGE}</strong>:通知内容。</ol></details><br>其他注意事项请前往授权管理页面查看。", "workflow_node.notify.form.webhook_data.guide": "<details><summary>支持的变量:</summary><ol style=\"margin-left: 1.25em; list-style: disc;\"><li><strong>${SUBJECT}</strong>:通知主题。</li><li><strong>${MESSAGE}</strong>:通知内容。</ol></details><br>其他注意事项请前往授权管理页面查看。",
"workflow_node.notify.form.webhook_data.errmsg.json_invalid": "请输入有效的 JSON 格式字符串", "workflow_node.notify.form.webhook_data.errmsg.json_invalid": "请输入有效的 JSON 格式字符串",
"workflow_node.notify.form.strategy_config.label": "执行策略",
"workflow_node.notify.form.skip_on_all_prev_skipped.label": "静默行为",
"workflow_node.notify.form.skip_on_all_prev_skipped.prefix": "当前序申请、上传、部署等节点均已跳过执行时,",
"workflow_node.notify.form.skip_on_all_prev_skipped.suffix": "此通知节点。",
"workflow_node.notify.form.skip_on_all_prev_skipped.switch.on": "跳过",
"workflow_node.notify.form.skip_on_all_prev_skipped.switch.off": "不跳过",
"workflow_node.end.label": "结束", "workflow_node.end.label": "结束",
"workflow_node.end.default_name": "结束", "workflow_node.end.default_name": "结束",

View File

@ -9,6 +9,7 @@ import {
EditOutlined as EditOutlinedIcon, EditOutlined as EditOutlinedIcon,
PlusOutlined as PlusOutlinedIcon, PlusOutlined as PlusOutlinedIcon,
ReloadOutlined as ReloadOutlinedIcon, ReloadOutlined as ReloadOutlinedIcon,
SnippetsOutlined as SnippetsOutlinedIcon,
StopOutlined as StopOutlinedIcon, StopOutlined as StopOutlinedIcon,
SyncOutlined as SyncOutlinedIcon, SyncOutlined as SyncOutlinedIcon,
} from "@ant-design/icons"; } from "@ant-design/icons";
@ -39,7 +40,7 @@ import {
import dayjs from "dayjs"; import dayjs from "dayjs";
import { ClientResponseError } from "pocketbase"; import { ClientResponseError } from "pocketbase";
import { WORKFLOW_TRIGGERS, type WorkflowModel, isAllNodesValidated } from "@/domain/workflow"; import { WORKFLOW_TRIGGERS, type WorkflowModel, cloneNode, initWorkflow, isAllNodesValidated } from "@/domain/workflow";
import { WORKFLOW_RUN_STATUSES } from "@/domain/workflowRun"; import { WORKFLOW_RUN_STATUSES } from "@/domain/workflowRun";
import { list as listWorkflows, remove as removeWorkflow, save as saveWorkflow } from "@/repository/workflow"; import { list as listWorkflows, remove as removeWorkflow, save as saveWorkflow } from "@/repository/workflow";
import { getErrMsg } from "@/utils/error"; import { getErrMsg } from "@/utils/error";
@ -219,6 +220,17 @@ const WorkflowList = () => {
/> />
</Tooltip> </Tooltip>
<Tooltip title={t("workflow.action.duplicate")}>
<Button
color="primary"
icon={<SnippetsOutlinedIcon />}
variant="text"
onClick={() => {
handleDuplicateClick(record);
}}
/>
</Tooltip>
<Tooltip title={t("workflow.action.delete")}> <Tooltip title={t("workflow.action.delete")}>
<Button <Button
color="danger" color="danger"
@ -321,6 +333,36 @@ const WorkflowList = () => {
} }
}; };
const handleDuplicateClick = (workflow: WorkflowModel) => {
modalApi.confirm({
title: t("workflow.action.duplicate"),
content: t("workflow.action.duplicate.confirm"),
onOk: async () => {
try {
const workflowCopy = {
name: `${workflow.name}-copy`,
description: workflow.description,
trigger: workflow.trigger,
triggerCron: workflow.triggerCron,
draft: workflow.content
? cloneNode(workflow.content, { withCopySuffix: false })
: workflow.draft
? cloneNode(workflow.draft, { withCopySuffix: false })
: initWorkflow().draft,
hasDraft: true,
} as WorkflowModel;
const resp = await saveWorkflow(workflowCopy);
if (resp) {
refreshData();
}
} catch (err) {
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
}
},
});
};
const handleDeleteClick = (workflow: WorkflowModel) => { const handleDeleteClick = (workflow: WorkflowModel) => {
modalApi.confirm({ modalApi.confirm({
title: t("workflow.action.delete"), title: t("workflow.action.delete"),