Merge pull request #25 from woodchen-ink:usual2970-main

Usual2970-main
This commit is contained in:
wood chen 2025-06-11 20:32:20 +08:00 committed by GitHub
commit b60e30bb8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 447 additions and 144 deletions

52
.goreleaser.linux.yml Normal file
View File

@ -0,0 +1,52 @@
# .goreleaser.linux.yml
project_name: certimate
dist: .builds/linux
before:
hooks:
- go mod tidy
builds:
- id: build_linux
main: ./
binary: certimate
ldflags:
- -s -w -X github.com/usual2970/certimate.Version={{ .Version }}
env:
- CGO_ENABLED=0
goos:
- linux
goarch:
- amd64
- arm64
- arm
goarm:
- 7
release:
draft: true
ids:
- linux
archives:
- id: archive_linux
builds: [build_linux]
format: "zip"
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
files:
- CHANGELOG.md
- LICENSE.md
- README.md
checksum:
name_template: "checksums_linux.txt"
snapshot:
name_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
filters:
exclude:
- "^ui:"

49
.goreleaser.macos.yml Normal file
View File

@ -0,0 +1,49 @@
# .goreleaser.macos.yml
project_name: certimate
dist: .builds/macos
before:
hooks:
- go mod tidy
builds:
- id: build_macos
main: ./
binary: certimate
ldflags:
- -s -w -X github.com/usual2970/certimate.Version={{ .Version }}
env:
- CGO_ENABLED=0
goos:
- darwin
goarch:
- amd64
- arm64
release:
draft: true
ids:
- macos
archives:
- id: archive_macos
builds: [build_macos]
format: "zip"
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
files:
- CHANGELOG.md
- LICENSE.md
- README.md
checksum:
name_template: "checksums_macos.txt"
snapshot:
name_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
filters:
exclude:
- "^ui:"

52
.goreleaser.windows.yml Normal file
View File

@ -0,0 +1,52 @@
# .goreleaser.windows.yml
project_name: certimate
dist: .builds/windows
before:
hooks:
- go mod tidy
builds:
- id: build_windows
main: ./
binary: certimate
ldflags:
- -s -w -X github.com/usual2970/certimate.Version={{ .Version }}
env:
- CGO_ENABLED=0
goos:
- windows
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm
release:
draft: true
ids:
- windows
archives:
- id: archive_windows
builds: [build_windows]
format: "zip"
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
files:
- CHANGELOG.md
- LICENSE.md
- README.md
checksum:
name_template: "checksums_windows.txt"
snapshot:
name_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
filters:
exclude:
- "^ui:"

View File

@ -106,12 +106,13 @@ type WorkflowNodeConfigForDeploy struct {
}
type WorkflowNodeConfigForNotify struct {
Channel string `json:"channel,omitempty"` // Deprecated: v0.4.x 将废弃
Provider string `json:"provider"` // 通知提供商
ProviderAccessId string `json:"providerAccessId"` // 通知提供商授权记录 ID
ProviderConfig map[string]any `json:"providerConfig,omitempty"` // 通知提供商额外配置
Subject string `json:"subject"` // 通知主题
Message string `json:"message"` // 通知内容
Channel string `json:"channel,omitempty"` // Deprecated: v0.4.x 将废弃
Provider string `json:"provider"` // 通知提供商
ProviderAccessId string `json:"providerAccessId"` // 通知提供商授权记录 ID
ProviderConfig map[string]any `json:"providerConfig,omitempty"` // 通知提供商额外配置
Subject string `json:"subject"` // 通知主题
Message string `json:"message"` // 通知内容
SkipOnAllPrevSkipped bool `json:"skipOnAllPrevSkipped"` // 前序节点均已跳过时是否跳过
}
type WorkflowNodeConfigForCondition struct {
@ -128,7 +129,7 @@ func (n *WorkflowNode) GetConfigForApply() WorkflowNodeConfigForApply {
CAProvider: maputil.GetString(n.Config, "caProvider"),
CAProviderAccessId: maputil.GetString(n.Config, "caProviderAccessId"),
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"),
DnsPropagationWait: maputil.GetInt32(n.Config, "dnsPropagationWait"),
DnsPropagationTimeout: maputil.GetInt32(n.Config, "dnsPropagationTimeout"),
@ -169,12 +170,13 @@ func (n *WorkflowNode) GetConfigForDeploy() WorkflowNodeConfigForDeploy {
func (n *WorkflowNode) GetConfigForNotify() WorkflowNodeConfigForNotify {
return WorkflowNodeConfigForNotify{
Channel: maputil.GetString(n.Config, "channel"),
Provider: maputil.GetString(n.Config, "provider"),
ProviderAccessId: maputil.GetString(n.Config, "providerAccessId"),
ProviderConfig: maputil.GetKVMapAny(n.Config, "providerConfig"),
Subject: maputil.GetString(n.Config, "subject"),
Message: maputil.GetString(n.Config, "message"),
Channel: maputil.GetString(n.Config, "channel"),
Provider: maputil.GetString(n.Config, "provider"),
ProviderAccessId: maputil.GetString(n.Config, "providerAccessId"),
ProviderConfig: maputil.GetKVMapAny(n.Config, "providerConfig"),
Subject: maputil.GetString(n.Config, "subject"),
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(),
ListenerPort: uint16(cloudHttpsListenerPort),
Scheduler: describeAppHTTPSListenersResp.ListenerList[0].Scheduler,
CertIds: describeAppHTTPSListenersResp.ListenerList[0].CertIds,
AdditionalCertDomains: sliceutil.Map(describeAppHTTPSListenersResp.ListenerList[0].AdditionalCertDomains, func(domain bceappblb.AdditionalCertDomainsModel) bceappblb.AdditionalCertDomainsModel {
if domain.Host == d.config.Domain {
return bceappblb.AdditionalCertDomainsModel{

View File

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

View File

@ -136,7 +136,7 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE
}
// 循环获取部署任务详情,等待任务状态变更
// REF: https://cloud.tencent.com.cn/document/api/400/91658
// REF: https://cloud.tencent.com/document/api/400/91658
for {
select {
case <-ctx.Done():

View File

@ -153,7 +153,7 @@ func (d *DeployerProvider) deployViaSslService(ctx context.Context, cloudCertId
}
// 循环获取部署任务详情,等待任务状态变更
// REF: https://cloud.tencent.com.cn/document/api/400/91658
// REF: https://cloud.tencent.com/document/api/400/91658
for {
select {
case <-ctx.Done():

View File

@ -104,7 +104,7 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE
}
// 循环获取部署任务详情,等待任务状态变更
// REF: https://cloud.tencent.com.cn/document/api/400/91658
// REF: https://cloud.tencent.com/document/api/400/91658
for {
select {
case <-ctx.Done():

View File

@ -119,7 +119,7 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE
}
// 循环获取部署任务详情,等待任务状态变更
// REF: https://cloud.tencent.com.cn/document/api/400/91658
// REF: https://cloud.tencent.com/document/api/400/91658
for {
select {
case <-ctx.Done():

View File

@ -106,7 +106,7 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE
}
// 循环获取部署任务详情,等待任务状态变更
// REF: https://cloud.tencent.com.cn/document/api/400/91658
// REF: https://cloud.tencent.com/document/api/400/91658
for {
select {
case <-ctx.Done():

View File

@ -6,11 +6,13 @@ import (
"fmt"
"log/slog"
"strconv"
"strings"
"github.com/usual2970/certimate/internal/pkg/core/deployer"
"github.com/usual2970/certimate/internal/pkg/core/uploader"
uploadersp "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/wangsu-certificate"
wangsusdk "github.com/usual2970/certimate/internal/pkg/sdk3rd/wangsu/cdn"
sliceutil "github.com/usual2970/certimate/internal/pkg/utils/slice"
)
type DeployerConfig struct {
@ -18,7 +20,7 @@ type DeployerConfig struct {
AccessKeyId string `json:"accessKeyId"`
// 网宿云 AccessKeySecret。
AccessKeySecret string `json:"accessKeySecret"`
// 加速域名数组
// 加速域名数组(支持泛域名)
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)
batchUpdateCertificateConfigReq := &wangsusdk.BatchUpdateCertificateConfigRequest{
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)
d.logger.Debug("sdk request 'cdn.BatchUpdateCertificateConfig'", slog.Any("request", batchUpdateCertificateConfigReq), slog.Any("response", batchUpdateCertificateConfigResp))

View File

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

View File

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

View File

@ -3,6 +3,7 @@ package nodeprocessor
import (
"context"
"fmt"
"log/slog"
"strconv"
"time"
@ -35,7 +36,8 @@ func NewApplyNode(node *domain.WorkflowNode) *applyNode {
}
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)
@ -45,6 +47,7 @@ func (n *applyNode) Process(ctx context.Context) error {
// 检测是否可以跳过本次执行
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))
return nil
} else if reason != "" {
@ -101,8 +104,8 @@ func (n *applyNode) Process(ctx context.Context) error {
}
// 保存 ARI 记录
if applyResult.ARIReplaced {
lastCertificate, _ := n.certRepo.GetByWorkflowRunId(ctx, lastOutput.RunId)
if applyResult.ARIReplaced && lastOutput != nil {
lastCertificate, _ := n.certRepo.GetByWorkflowRunIdAndNodeId(ctx, lastOutput.RunId, lastOutput.NodeId)
if lastCertificate != nil {
lastCertificate.ACMERenewed = true
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[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) {
if lastOutput != nil && lastOutput.Succeeded {
// 比较和上次申请时的关键配置(即影响证书签发的)参数是否一致
currentNodeConfig := n.node.GetConfigForApply()
lastNodeConfig := lastOutput.Node.GetConfigForApply()
if currentNodeConfig.Domains != lastNodeConfig.Domains {
thisNodeCfg := n.node.GetConfigForApply()
lastNodeCfg := lastOutput.Node.GetConfigForApply()
if thisNodeCfg.Domains != lastNodeCfg.Domains {
return false, "the configuration item 'Domains' changed"
}
if currentNodeConfig.ContactEmail != lastNodeConfig.ContactEmail {
if thisNodeCfg.ContactEmail != lastNodeCfg.ContactEmail {
return false, "the configuration item 'ContactEmail' changed"
}
if currentNodeConfig.Provider != lastNodeConfig.Provider {
if thisNodeCfg.Provider != lastNodeCfg.Provider {
return false, "the configuration item 'Provider' changed"
}
if currentNodeConfig.ProviderAccessId != lastNodeConfig.ProviderAccessId {
if thisNodeCfg.ProviderAccessId != lastNodeCfg.ProviderAccessId {
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"
}
if currentNodeConfig.CAProvider != lastNodeConfig.CAProvider {
if thisNodeCfg.CAProvider != lastNodeCfg.CAProvider {
return false, "the configuration item 'CAProvider' changed"
}
if currentNodeConfig.CAProviderAccessId != lastNodeConfig.CAProviderAccessId {
if thisNodeCfg.CAProviderAccessId != lastNodeCfg.CAProviderAccessId {
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"
}
if currentNodeConfig.KeyAlgorithm != lastNodeConfig.KeyAlgorithm {
if thisNodeCfg.KeyAlgorithm != lastNodeCfg.KeyAlgorithm {
return false, "the configuration item 'KeyAlgorithm' changed"
}
lastCertificate, _ := n.certRepo.GetByWorkflowRunId(ctx, lastOutput.RunId)
lastCertificate, _ := n.certRepo.GetByWorkflowRunIdAndNodeId(ctx, lastOutput.RunId, lastOutput.NodeId)
if lastCertificate != nil {
renewalInterval := time.Duration(currentNodeConfig.SkipBeforeExpiryDays) * time.Hour * 24
renewalInterval := time.Duration(thisNodeCfg.SkipBeforeExpiryDays) * time.Hour * 24
expirationTime := time.Until(lastCertificate.ExpireAt)
if expirationTime > renewalInterval {
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[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) {
variables := GetNodeOutputs(ctx)
variables := GetAllNodeOutputs(ctx)
return expression.Eval(variables)
}

View File

@ -3,4 +3,5 @@ package nodeprocessor
const (
outputKeyForCertificateValidity = "certificate.validity"
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 {
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 {
container := getNodeOutputsContainer(ctx)
if container == nil {
return nil
container = newNodeOutputsContainer()
}
container.RLock()
@ -69,22 +78,11 @@ func GetNodeOutput(ctx context.Context, nodeId string) map[string]any {
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)
if container == nil {
return nil
container = newNodeOutputsContainer()
}
container.RLock()
@ -103,26 +101,3 @@ func GetNodeOutputs(ctx context.Context) map[string]map[string]any {
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"
"fmt"
"log/slog"
"strconv"
"strings"
"github.com/usual2970/certimate/internal/deployer"
@ -33,7 +34,8 @@ func NewDeployNode(node *domain.WorkflowNode) *deployNode {
}
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)
@ -58,6 +60,7 @@ func (n *deployNode) Process(ctx context.Context) error {
// 检测是否可以跳过本次执行
if lastOutput != nil && certificate.CreatedAt.Before(lastOutput.UpdatedAt) {
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))
return nil
} else if reason != "" {
@ -96,6 +99,9 @@ func (n *deployNode) Process(ctx context.Context) error {
return err
}
// 记录中间结果
n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(false)
n.logger.Info("deployment completed")
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) {
if lastOutput != nil && lastOutput.Succeeded {
// 比较和上次部署时的关键配置(即影响证书部署的)参数是否一致
currentNodeConfig := n.node.GetConfigForDeploy()
lastNodeConfig := lastOutput.Node.GetConfigForDeploy()
if currentNodeConfig.ProviderAccessId != lastNodeConfig.ProviderAccessId {
thisNodeCfg := n.node.GetConfigForDeploy()
lastNodeCfg := lastOutput.Node.GetConfigForDeploy()
if thisNodeCfg.ProviderAccessId != lastNodeCfg.ProviderAccessId {
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"
}
if currentNodeConfig.SkipOnLastSucceeded {
if thisNodeCfg.SkipOnLastSucceeded {
return true, "the certificate has already been deployed"
}
}

View File

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

View File

@ -2,7 +2,9 @@ package nodeprocessor
import (
"context"
"fmt"
"log/slog"
"strconv"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/notify"
@ -28,9 +30,8 @@ func NewNotifyNode(node *domain.WorkflowNode) *notifyNode {
}
func (n *notifyNode) Process(ctx context.Context) error {
n.logger.Info("ready to send notification ...")
nodeCfg := n.node.GetConfigForNotify()
n.logger.Info("ready to send notification ...", slog.Any("config", nodeCfg))
if nodeCfg.Provider == "" {
// Deprecated: v0.4.x 将废弃
@ -59,6 +60,12 @@ func (n *notifyNode) Process(ctx context.Context) error {
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{
Node: n.node,
@ -80,3 +87,21 @@ func (n *notifyNode) Process(ctx context.Context) error {
n.logger.Info("notification completed")
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,7 @@ func (n *nodeOutputer) GetOutputs() map[string]any {
type certificateRepository interface {
GetByWorkflowNodeId(ctx context.Context, workflowNodeId string) (*domain.Certificate, error)
GetByWorkflowRunId(ctx context.Context, workflowRunId string) (*domain.Certificate, error)
GetByWorkflowRunIdAndNodeId(ctx context.Context, workflowRunId string, workflowNodeId string) (*domain.Certificate, error)
Save(ctx context.Context, certificate *domain.Certificate) (*domain.Certificate, error)
}

View File

@ -3,6 +3,7 @@ package nodeprocessor
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
@ -32,9 +33,8 @@ func NewUploadNode(node *domain.WorkflowNode) *uploadNode {
}
func (n *uploadNode) Process(ctx context.Context) error {
n.logger.Info("ready to upload certiticate ...")
nodeCfg := n.node.GetConfigForUpload()
n.logger.Info("ready to upload certiticate ...", slog.Any("config", nodeCfg))
// 查询上次执行结果
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 {
n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(true)
n.logger.Info(fmt.Sprintf("skip this uploading, because %s", reason))
return nil
} 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[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) {
if lastOutput != nil && lastOutput.Succeeded {
// 比较和上次上传时的关键配置(即影响证书上传的)参数是否一致
currentNodeConfig := n.node.GetConfigForUpload()
lastNodeConfig := lastOutput.Node.GetConfigForUpload()
if strings.TrimSpace(currentNodeConfig.Certificate) != strings.TrimSpace(lastNodeConfig.Certificate) {
thisNodeCfg := n.node.GetConfigForUpload()
lastNodeCfg := lastOutput.Node.GetConfigForUpload()
if strings.TrimSpace(thisNodeCfg.Certificate) != strings.TrimSpace(lastNodeCfg.Certificate) {
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"
}
lastCertificate, _ := n.certRepo.GetByWorkflowRunId(ctx, lastOutput.RunId)
lastCertificate, _ := n.certRepo.GetByWorkflowRunIdAndNodeId(ctx, lastOutput.RunId, lastOutput.NodeId)
if lastCertificate != nil {
daysLeft := int(time.Until(lastCertificate.ExpireAt).Hours() / 24)
n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(daysLeft > 0)

View File

@ -28,7 +28,7 @@ import ACMEDns01ProviderSelect from "@/components/provider/ACMEDns01ProviderSele
import CAProviderSelect from "@/components/provider/CAProviderSelect";
import Show from "@/components/Show";
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 { useAccessesStore } from "@/stores/access";
import { useContactEmailsStore } from "@/stores/contact";
@ -59,11 +59,7 @@ export type ApplyNodeConfigFormInstance = {
const MULTIPLE_INPUT_SEPARATOR = ";";
const initFormModel = (): ApplyNodeConfigFormFieldValues => {
return {
challengeType: "dns-01",
keyAlgorithm: "RSA2048",
skipBeforeExpiryDays: 30,
};
return defaultNodeConfigForApply();
};
const ApplyNodeConfigForm = forwardRef<ApplyNodeConfigFormInstance, ApplyNodeConfigFormProps>(

View File

@ -4,7 +4,7 @@ import { Form, type FormInstance } from "antd";
import { createSchemaFieldRule } from "antd-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 ConditionNodeConfigFormExpressionEditor, { type ConditionNodeConfigFormExpressionEditorInstance } from "./ConditionNodeConfigFormExpressionEditor";
@ -29,7 +29,7 @@ export type ConditionNodeConfigFormInstance = {
};
const initFormModel = (): ConditionNodeConfigFormFieldValues => {
return {};
return defaultNodeConfigForCondition();
};
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 Show from "@/components/Show";
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 { useWorkflowStore } from "@/stores/workflow";
@ -117,9 +117,7 @@ export type DeployNodeConfigFormInstance = {
};
const initFormModel = (): DeployNodeConfigFormFieldValues => {
return {
skipOnLastSucceeded: true,
};
return defaultNodeConfigForDeploy();
};
const DeployNodeConfigForm = forwardRef<DeployNodeConfigFormInstance, DeployNodeConfigFormProps>(

View File

@ -43,7 +43,7 @@ const DeployNodeConfigFormWangsuCDNConfig = ({
if (!v) return false;
return String(v)
.split(MULTIPLE_INPUT_SEPARATOR)
.every((e) => validDomainName(e));
.every((e) => validDomainName(e, { allowWildcard: true }));
}, t("workflow_node.deploy.form.wangsu_cdn_domains.placeholder")),
});
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 { z } from "zod";
import { type WorkflowNodeConfigForMonitor } from "@/domain/workflow";
import { type WorkflowNodeConfigForMonitor, defaultNodeConfigForMonitor } from "@/domain/workflow";
import { useAntdForm } from "@/hooks";
import { validDomainName, validIPv4Address, validIPv6Address, validPortNumber } from "@/utils/validators";
@ -25,11 +25,7 @@ export type MonitorNodeConfigFormInstance = {
};
const initFormModel = (): MonitorNodeConfigFormFieldValues => {
return {
host: "",
port: 443,
requestPath: "/",
};
return defaultNodeConfigForMonitor();
};
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 { Link } from "react-router";
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 { z } from "zod";
@ -12,7 +12,7 @@ import NotificationProviderSelect from "@/components/provider/NotificationProvid
import Show from "@/components/Show";
import { ACCESS_USAGES, NOTIFICATION_PROVIDERS, accessProvidersMap, notificationProvidersMap } from "@/domain/provider";
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 { useAccessesStore } from "@/stores/access";
import { useNotifyChannelsStore } from "@/stores/notify";
@ -41,7 +41,7 @@ export type NotifyNodeConfigFormInstance = {
};
const initFormModel = (): NotifyNodeConfigFormFieldValues => {
return {};
return defaultNodeConfigForNotify();
};
const NotifyNodeConfigForm = forwardRef<NotifyNodeConfigFormInstance, NotifyNodeConfigFormProps>(
@ -74,6 +74,7 @@ const NotifyNodeConfigForm = forwardRef<NotifyNodeConfigFormInstance, NotifyNode
.string({ message: t("workflow_node.notify.form.provider_access.placeholder") })
.nonempty(t("workflow_node.notify.form.provider_access.placeholder")),
providerConfig: z.any().nullish(),
skipOnAllPrevSkipped: z.boolean().nullish(),
});
const formRule = createSchemaFieldRule(formSchema);
const { form: formInst, formProps } = useAntdForm({
@ -281,6 +282,27 @@ const NotifyNodeConfigForm = forwardRef<NotifyNodeConfigFormInstance, NotifyNode
{nestedFormEl}
</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>
);
}

View File

@ -6,7 +6,7 @@ import dayjs from "dayjs";
import { z } from "zod";
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 { getNextCronExecutions, validCronExpression } from "@/utils/cron";
@ -27,10 +27,7 @@ export type StartNodeConfigFormInstance = {
};
const initFormModel = (): StartNodeConfigFormFieldValues => {
return {
trigger: WORKFLOW_TRIGGERS.AUTO,
triggerCron: "0 0 * * *",
};
return defaultNodeConfigForStart();
};
const StartNodeConfigForm = forwardRef<StartNodeConfigFormInstance, StartNodeConfigFormProps>(

View File

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

View File

@ -1 +1 @@
export const version = "v0.3.16";
export const version = "v0.3.17";

View File

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

View File

@ -7,6 +7,8 @@
"workflow.action.create": "Create 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.confirm": "Are you sure to delete this workflow?",
"workflow.action.enable": "Enable",

View File

@ -682,11 +682,11 @@
"workflow_node.deploy.form.tencentcloud_ssl_deploy_region.tooltip": "For more information, see <a href=\"https://www.tencentcloud.com/document/product/1007/36573\" target=\"_blank\">https://www.tencentcloud.com/document/product/1007/36573</a>",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_type.label": "Tencent Cloud resource type",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_type.placeholder": "Please enter Tencent Cloud resource type",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_type.tooltip": "For more information, see <a href=\"https://cloud.tencent.com.cn/document/product/400/91667\" target=\"_blank\">https://cloud.tencent.com.cn/document/product/400/91667</a>",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_type.tooltip": "For more information, see <a href=\"https://cloud.tencent.com/document/product/400/91667\" target=\"_blank\">https://cloud.tencent.com/document/product/400/91667</a>",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.label": "Tencent Cloud resource IDs",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.placeholder": "Please enter Tencent Cloud resource IDs (separated by semicolons)",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.errmsg.invalid": "Please enter a valid Tencent Cloud resource ID",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.tooltip": "For more information, see <a href=\"https://cloud.tencent.com.cn/document/product/400/91667\" target=\"_blank\">https://cloud.tencent.com.cn/document/product/400/91667</a>",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.tooltip": "For more information, see <a href=\"https://cloud.tencent.com/document/product/400/91667\" target=\"_blank\">https://cloud.tencent.com/document/product/400/91667</a>",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.multiple_input_modal.title": "Change Tencent Cloud resource IDs",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.multiple_input_modal.placeholder": "Please enter Tencent Cloud resouce ID",
"workflow_node.deploy.form.tencentcloud_vod_sub_app_id.label": "Tencent Cloud VOD App ID",
@ -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.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.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.default_name": "End",

View File

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

View File

@ -678,14 +678,14 @@
"workflow_node.deploy.form.tencentcloud_ssl_deploy.guide": "小贴士:由于腾讯云证书部署任务是异步的,此节点若执行成功仅代表已创建部署任务,实际部署结果需要你自行前往腾讯云控制台查询。",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_region.label": "腾讯云云产品地域",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_region.placeholder": "请输入腾讯云云产品地域例如ap-guangzhou",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_region.tooltip": "这是什么?请参阅 <a href=\"https://cloud.tencent.com.cn/document/product/400/41659\" target=\"_blank\">https://cloud.tencent.com.cn/document/product/400/41659</a>",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_region.tooltip": "这是什么?请参阅 <a href=\"https://cloud.tencent.com/document/product/400/41659\" target=\"_blank\">https://cloud.tencent.com/document/product/400/41659</a>",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_type.label": "腾讯云云产品资源类型",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_type.placeholder": "请输入腾讯云产品资源类型",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_type.tooltip": "这是什么?请参阅 <a href=\"https://cloud.tencent.com.cn/document/product/400/91667\" target=\"_blank\">https://cloud.tencent.com.cn/document/product/400/91667</a>",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_type.tooltip": "这是什么?请参阅 <a href=\"https://cloud.tencent.com/document/product/400/91667\" target=\"_blank\">https://cloud.tencent.com/document/product/400/91667</a>",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.label": "腾讯云云产品资源 ID",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.placeholder": "请输入腾讯云云产品资源 ID多个值请用半角分号隔开",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.errmsg.invalid": "请输入正确的腾讯云云产品资源 ID",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.tooltip": "这是什么?请参阅 <a href=\"https://cloud.tencent.com.cn/document/product/400/91667\" target=\"_blank\">https://cloud.tencent.com.cn/document/product/400/91667</a><br><br>注意与各产品本身的实例 ID 区分。",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.tooltip": "这是什么?请参阅 <a href=\"https://cloud.tencent.com/document/product/400/91667\" target=\"_blank\">https://cloud.tencent.com/document/product/400/91667</a><br><br>注意与各产品本身的实例 ID 区分。",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.multiple_input_modal.title": "修改腾讯云云产品资源 ID",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.multiple_input_modal.placeholder": "请输入腾讯云云产品资源 ID",
"workflow_node.deploy.form.tencentcloud_vod_sub_app_id.label": "腾讯云云点播应用 ID",
@ -795,7 +795,7 @@
"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.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.multiple_input_modal.title": "修改网宿云 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.message.label": "通知内容",
"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.button": "设置",
"workflow_node.notify.form.provider.label": "通知渠道",
@ -877,6 +877,12 @@
"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.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.default_name": "结束",

View File

@ -9,6 +9,7 @@ import {
EditOutlined as EditOutlinedIcon,
PlusOutlined as PlusOutlinedIcon,
ReloadOutlined as ReloadOutlinedIcon,
SnippetsOutlined as SnippetsOutlinedIcon,
StopOutlined as StopOutlinedIcon,
SyncOutlined as SyncOutlinedIcon,
} from "@ant-design/icons";
@ -39,7 +40,7 @@ import {
import dayjs from "dayjs";
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 { list as listWorkflows, remove as removeWorkflow, save as saveWorkflow } from "@/repository/workflow";
import { getErrMsg } from "@/utils/error";
@ -219,6 +220,17 @@ const WorkflowList = () => {
/>
</Tooltip>
<Tooltip title={t("workflow.action.duplicate")}>
<Button
color="primary"
icon={<SnippetsOutlinedIcon />}
variant="text"
onClick={() => {
handleDuplicateClick(record);
}}
/>
</Tooltip>
<Tooltip title={t("workflow.action.delete")}>
<Button
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) => {
modalApi.confirm({
title: t("workflow.action.delete"),