diff --git a/internal/domain/workflow.go b/internal/domain/workflow.go index 55fe56ed..256ad08d 100644 --- a/internal/domain/workflow.go +++ b/internal/domain/workflow.go @@ -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 { @@ -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"), } } diff --git a/internal/workflow/dispatcher/invoker.go b/internal/workflow/dispatcher/invoker.go index b6e4a4db..c1d1260e 100644 --- a/internal/workflow/dispatcher/invoker.go +++ b/internal/workflow/dispatcher/invoker.go @@ -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 diff --git a/internal/workflow/node-processor/apply_node.go b/internal/workflow/node-processor/apply_node.go index 291b604d..96cbabfb 100644 --- a/internal/workflow/node-processor/apply_node.go +++ b/internal/workflow/node-processor/apply_node.go @@ -47,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 != "" { @@ -112,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) @@ -122,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) 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) @@ -162,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) } } } diff --git a/internal/workflow/node-processor/condition_node.go b/internal/workflow/node-processor/condition_node.go index d9e8126d..023f9e1a 100644 --- a/internal/workflow/node-processor/condition_node.go +++ b/internal/workflow/node-processor/condition_node.go @@ -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) } diff --git a/internal/workflow/node-processor/const.go b/internal/workflow/node-processor/const.go index 62d2d56b..21bdf167 100644 --- a/internal/workflow/node-processor/const.go +++ b/internal/workflow/node-processor/const.go @@ -3,4 +3,5 @@ package nodeprocessor const ( outputKeyForCertificateValidity = "certificate.validity" outputKeyForCertificateDaysLeft = "certificate.daysLeft" + outputKeyForNodeSkipped = "node.skipped" ) diff --git a/internal/workflow/node-processor/context.go b/internal/workflow/node-processor/context.go index 96c40487..d600554d 100644 --- a/internal/workflow/node-processor/context.go +++ b/internal/workflow/node-processor/context.go @@ -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 -} diff --git a/internal/workflow/node-processor/deploy_node.go b/internal/workflow/node-processor/deploy_node.go index 279893e8..30a7c4e7 100644 --- a/internal/workflow/node-processor/deploy_node.go +++ b/internal/workflow/node-processor/deploy_node.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "strconv" "strings" "github.com/usual2970/certimate/internal/deployer" @@ -59,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 != "" { @@ -97,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 } @@ -104,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" } } diff --git a/internal/workflow/node-processor/notify_node.go b/internal/workflow/node-processor/notify_node.go index 9d259c0a..2cba06cf 100644 --- a/internal/workflow/node-processor/notify_node.go +++ b/internal/workflow/node-processor/notify_node.go @@ -2,7 +2,9 @@ package nodeprocessor import ( "context" + "fmt" "log/slog" + "strconv" "github.com/usual2970/certimate/internal/domain" "github.com/usual2970/certimate/internal/notify" @@ -58,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, @@ -79,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 +} diff --git a/internal/workflow/node-processor/upload_node.go b/internal/workflow/node-processor/upload_node.go index adbf46dd..c06d0e82 100644 --- a/internal/workflow/node-processor/upload_node.go +++ b/internal/workflow/node-processor/upload_node.go @@ -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,12 +83,13 @@ 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" } diff --git a/ui/src/components/workflow/node/NotifyNodeConfigForm.tsx b/ui/src/components/workflow/node/NotifyNodeConfigForm.tsx index 4473da6a..09c6e02e 100644 --- a/ui/src/components/workflow/node/NotifyNodeConfigForm.tsx +++ b/ui/src/components/workflow/node/NotifyNodeConfigForm.tsx @@ -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"; @@ -74,6 +74,7 @@ const NotifyNodeConfigForm = forwardRef + + + + {t("workflow_node.notify.form.strategy_config.label")} + + + +
+ + +
{t("workflow_node.notify.form.skip_on_all_prev_skipped.prefix")}
+ + + +
{t("workflow_node.notify.form.skip_on_all_prev_skipped.suffix")}
+
+
+
); } diff --git a/ui/src/domain/workflow.ts b/ui/src/domain/workflow.ts index 50a0be4a..d9069d5b 100644 --- a/ui/src/domain/workflow.ts +++ b/ui/src/domain/workflow.ts @@ -216,6 +216,7 @@ export type WorkflowNodeConfigForNotify = { provider: string; providerAccessId: string; providerConfig?: Record; + skipOnAllPrevSkipped?: boolean; }; export const defaultNodeConfigForNotify = (): Partial => { diff --git a/ui/src/i18n/locales/en/nls.workflow.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json index 88d3dc15..742abcd7 100644 --- a/ui/src/i18n/locales/en/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/en/nls.workflow.nodes.json @@ -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": "
Supported variables:
  1. ${SUBJECT}: The subject of notification.
  2. ${MESSAGE}: The message of notification.

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", diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index e138e350..a1497782 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -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": "
支持的变量:
  1. ${SUBJECT}:通知主题。
  2. ${MESSAGE}:通知内容。

其他注意事项请前往授权管理页面查看。", "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": "结束",