refactor: workflow condition node

refactor: workflow condition node
This commit is contained in:
Fu Diwei 2025-05-28 23:30:38 +08:00
parent 3a829ad53b
commit 6731c465e7
59 changed files with 1140 additions and 988 deletions

View File

@ -53,35 +53,35 @@ func NewWithWorkflowNode(config ApplicantWithWorkflowNodeConfig) (Applicant, err
return nil, fmt.Errorf("node type is not '%s'", string(domain.WorkflowNodeTypeApply))
}
nodeConfig := config.Node.GetConfigForApply()
nodeCfg := config.Node.GetConfigForApply()
options := &applicantProviderOptions{
Domains: sliceutil.Filter(strings.Split(nodeConfig.Domains, ";"), func(s string) bool { return s != "" }),
ContactEmail: nodeConfig.ContactEmail,
Provider: domain.ACMEDns01ProviderType(nodeConfig.Provider),
Domains: sliceutil.Filter(strings.Split(nodeCfg.Domains, ";"), func(s string) bool { return s != "" }),
ContactEmail: nodeCfg.ContactEmail,
Provider: domain.ACMEDns01ProviderType(nodeCfg.Provider),
ProviderAccessConfig: make(map[string]any),
ProviderServiceConfig: nodeConfig.ProviderConfig,
CAProvider: domain.CAProviderType(nodeConfig.CAProvider),
ProviderServiceConfig: nodeCfg.ProviderConfig,
CAProvider: domain.CAProviderType(nodeCfg.CAProvider),
CAProviderAccessConfig: make(map[string]any),
CAProviderServiceConfig: nodeConfig.CAProviderConfig,
KeyAlgorithm: nodeConfig.KeyAlgorithm,
Nameservers: sliceutil.Filter(strings.Split(nodeConfig.Nameservers, ";"), func(s string) bool { return s != "" }),
DnsPropagationWait: nodeConfig.DnsPropagationWait,
DnsPropagationTimeout: nodeConfig.DnsPropagationTimeout,
DnsTTL: nodeConfig.DnsTTL,
DisableFollowCNAME: nodeConfig.DisableFollowCNAME,
CAProviderServiceConfig: nodeCfg.CAProviderConfig,
KeyAlgorithm: nodeCfg.KeyAlgorithm,
Nameservers: sliceutil.Filter(strings.Split(nodeCfg.Nameservers, ";"), func(s string) bool { return s != "" }),
DnsPropagationWait: nodeCfg.DnsPropagationWait,
DnsPropagationTimeout: nodeCfg.DnsPropagationTimeout,
DnsTTL: nodeCfg.DnsTTL,
DisableFollowCNAME: nodeCfg.DisableFollowCNAME,
}
accessRepo := repository.NewAccessRepository()
if nodeConfig.ProviderAccessId != "" {
if access, err := accessRepo.GetById(context.Background(), nodeConfig.ProviderAccessId); err != nil {
return nil, fmt.Errorf("failed to get access #%s record: %w", nodeConfig.ProviderAccessId, err)
if nodeCfg.ProviderAccessId != "" {
if access, err := accessRepo.GetById(context.Background(), nodeCfg.ProviderAccessId); err != nil {
return nil, fmt.Errorf("failed to get access #%s record: %w", nodeCfg.ProviderAccessId, err)
} else {
options.ProviderAccessConfig = access.Config
}
}
if nodeConfig.CAProviderAccessId != "" {
if access, err := accessRepo.GetById(context.Background(), nodeConfig.CAProviderAccessId); err != nil {
return nil, fmt.Errorf("failed to get access #%s record: %w", nodeConfig.CAProviderAccessId, err)
if nodeCfg.CAProviderAccessId != "" {
if access, err := accessRepo.GetById(context.Background(), nodeCfg.CAProviderAccessId); err != nil {
return nil, fmt.Errorf("failed to get access #%s record: %w", nodeCfg.CAProviderAccessId, err)
} else {
options.CAProviderAccessId = access.Id
options.CAProviderAccessConfig = access.Config

View File

@ -29,18 +29,18 @@ func NewWithWorkflowNode(config DeployerWithWorkflowNodeConfig) (Deployer, error
return nil, fmt.Errorf("node type is not '%s'", string(domain.WorkflowNodeTypeDeploy))
}
nodeConfig := config.Node.GetConfigForDeploy()
nodeCfg := config.Node.GetConfigForDeploy()
options := &deployerProviderOptions{
Provider: domain.DeploymentProviderType(nodeConfig.Provider),
Provider: domain.DeploymentProviderType(nodeCfg.Provider),
ProviderAccessConfig: make(map[string]any),
ProviderServiceConfig: nodeConfig.ProviderConfig,
ProviderServiceConfig: nodeCfg.ProviderConfig,
}
accessRepo := repository.NewAccessRepository()
if nodeConfig.ProviderAccessId != "" {
access, err := accessRepo.GetById(context.Background(), nodeConfig.ProviderAccessId)
if nodeCfg.ProviderAccessId != "" {
access, err := accessRepo.GetById(context.Background(), nodeCfg.ProviderAccessId)
if err != nil {
return nil, fmt.Errorf("failed to get access #%s record: %w", nodeConfig.ProviderAccessId, err)
return nil, fmt.Errorf("failed to get access #%s record: %w", nodeCfg.ProviderAccessId, err)
} else {
options.ProviderAccessConfig = access.Config
}

View File

@ -1,4 +1,4 @@
package domain
package expr
import (
"encoding/json"
@ -6,41 +6,38 @@ import (
"strconv"
)
type Value any
type (
ComparisonOperator string
LogicalOperator string
ValueType string
ExprType string
ExprComparisonOperator string
ExprLogicalOperator string
ExprValueType string
)
const (
GreaterThan ComparisonOperator = ">"
LessThan ComparisonOperator = "<"
GreaterOrEqual ComparisonOperator = ">="
LessOrEqual ComparisonOperator = "<="
Equal ComparisonOperator = "=="
NotEqual ComparisonOperator = "!="
Is ComparisonOperator = "is"
GreaterThan ExprComparisonOperator = "gt"
GreaterOrEqual ExprComparisonOperator = "gte"
LessThan ExprComparisonOperator = "lt"
LessOrEqual ExprComparisonOperator = "lte"
Equal ExprComparisonOperator = "eq"
NotEqual ExprComparisonOperator = "neq"
And LogicalOperator = "and"
Or LogicalOperator = "or"
Not LogicalOperator = "not"
And ExprLogicalOperator = "and"
Or ExprLogicalOperator = "or"
Not ExprLogicalOperator = "not"
Number ValueType = "number"
String ValueType = "string"
Boolean ValueType = "boolean"
Number ExprValueType = "number"
String ExprValueType = "string"
Boolean ExprValueType = "boolean"
ConstExprType ExprType = "const"
VarExprType ExprType = "var"
CompareExprType ExprType = "compare"
ConstantExprType ExprType = "const"
VariantExprType ExprType = "var"
ComparisonExprType ExprType = "comparison"
LogicalExprType ExprType = "logical"
NotExprType ExprType = "not"
)
type EvalResult struct {
Type ValueType
Type ExprValueType
Value any
}
@ -88,13 +85,20 @@ func (e *EvalResult) GreaterThan(other *EvalResult) (*EvalResult, error) {
if e.Type != other.Type {
return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type)
}
switch e.Type {
case Number:
switch e.Type {
case String:
return &EvalResult{
Type: Boolean,
Value: e.Value.(string) > other.Value.(string),
}, nil
case Number:
left, err := e.GetFloat64()
if err != nil {
return nil, err
}
right, err := other.GetFloat64()
if err != nil {
return nil, err
@ -104,14 +108,9 @@ func (e *EvalResult) GreaterThan(other *EvalResult) (*EvalResult, error) {
Type: Boolean,
Value: left > right,
}, nil
case String:
return &EvalResult{
Type: Boolean,
Value: e.Value.(string) > other.Value.(string),
}, nil
default:
return nil, fmt.Errorf("unsupported type: %s", e.Type)
return nil, fmt.Errorf("unsupported value type: %s", e.Type)
}
}
@ -119,28 +118,32 @@ func (e *EvalResult) GreaterOrEqual(other *EvalResult) (*EvalResult, error) {
if e.Type != other.Type {
return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type)
}
switch e.Type {
case Number:
left, err := e.GetFloat64()
if err != nil {
return nil, err
}
right, err := other.GetFloat64()
if err != nil {
return nil, err
}
return &EvalResult{
Type: Boolean,
Value: left >= right,
}, nil
case String:
return &EvalResult{
Type: Boolean,
Value: e.Value.(string) >= other.Value.(string),
}, nil
case Number:
left, err := e.GetFloat64()
if err != nil {
return nil, err
}
right, err := other.GetFloat64()
if err != nil {
return nil, err
}
return &EvalResult{
Type: Boolean,
Value: left >= right,
}, nil
default:
return nil, fmt.Errorf("unsupported type: %s", e.Type)
return nil, fmt.Errorf("unsupported value type: %s", e.Type)
}
}
@ -148,28 +151,32 @@ func (e *EvalResult) LessThan(other *EvalResult) (*EvalResult, error) {
if e.Type != other.Type {
return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type)
}
switch e.Type {
case Number:
left, err := e.GetFloat64()
if err != nil {
return nil, err
}
right, err := other.GetFloat64()
if err != nil {
return nil, err
}
return &EvalResult{
Type: Boolean,
Value: left < right,
}, nil
case String:
return &EvalResult{
Type: Boolean,
Value: e.Value.(string) < other.Value.(string),
}, nil
case Number:
left, err := e.GetFloat64()
if err != nil {
return nil, err
}
right, err := other.GetFloat64()
if err != nil {
return nil, err
}
return &EvalResult{
Type: Boolean,
Value: left < right,
}, nil
default:
return nil, fmt.Errorf("unsupported type: %s", e.Type)
return nil, fmt.Errorf("unsupported value type: %s", e.Type)
}
}
@ -177,28 +184,32 @@ func (e *EvalResult) LessOrEqual(other *EvalResult) (*EvalResult, error) {
if e.Type != other.Type {
return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type)
}
switch e.Type {
case Number:
left, err := e.GetFloat64()
if err != nil {
return nil, err
}
right, err := other.GetFloat64()
if err != nil {
return nil, err
}
return &EvalResult{
Type: Boolean,
Value: left <= right,
}, nil
case String:
return &EvalResult{
Type: Boolean,
Value: e.Value.(string) <= other.Value.(string),
}, nil
case Number:
left, err := e.GetFloat64()
if err != nil {
return nil, err
}
right, err := other.GetFloat64()
if err != nil {
return nil, err
}
return &EvalResult{
Type: Boolean,
Value: left <= right,
}, nil
default:
return nil, fmt.Errorf("unsupported type: %s", e.Type)
return nil, fmt.Errorf("unsupported value type: %s", e.Type)
}
}
@ -206,28 +217,48 @@ func (e *EvalResult) Equal(other *EvalResult) (*EvalResult, error) {
if e.Type != other.Type {
return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type)
}
switch e.Type {
case Number:
left, err := e.GetFloat64()
if err != nil {
return nil, err
}
right, err := other.GetFloat64()
if err != nil {
return nil, err
}
return &EvalResult{
Type: Boolean,
Value: left == right,
}, nil
case String:
return &EvalResult{
Type: Boolean,
Value: e.Value.(string) == other.Value.(string),
}, nil
case Number:
left, err := e.GetFloat64()
if err != nil {
return nil, err
}
right, err := other.GetFloat64()
if err != nil {
return nil, err
}
return &EvalResult{
Type: Boolean,
Value: left == right,
}, nil
case Boolean:
left, err := e.GetBool()
if err != nil {
return nil, err
}
right, err := other.GetBool()
if err != nil {
return nil, err
}
return &EvalResult{
Type: Boolean,
Value: left == right,
}, nil
default:
return nil, fmt.Errorf("unsupported type: %s", e.Type)
return nil, fmt.Errorf("unsupported value type: %s", e.Type)
}
}
@ -235,28 +266,48 @@ func (e *EvalResult) NotEqual(other *EvalResult) (*EvalResult, error) {
if e.Type != other.Type {
return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type)
}
switch e.Type {
case Number:
left, err := e.GetFloat64()
if err != nil {
return nil, err
}
right, err := other.GetFloat64()
if err != nil {
return nil, err
}
return &EvalResult{
Type: Boolean,
Value: left != right,
}, nil
case String:
return &EvalResult{
Type: Boolean,
Value: e.Value.(string) != other.Value.(string),
}, nil
case Number:
left, err := e.GetFloat64()
if err != nil {
return nil, err
}
right, err := other.GetFloat64()
if err != nil {
return nil, err
}
return &EvalResult{
Type: Boolean,
Value: left != right,
}, nil
case Boolean:
left, err := e.GetBool()
if err != nil {
return nil, err
}
right, err := other.GetBool()
if err != nil {
return nil, err
}
return &EvalResult{
Type: Boolean,
Value: left != right,
}, nil
default:
return nil, fmt.Errorf("unsupported type: %s", e.Type)
return nil, fmt.Errorf("unsupported value type: %s", e.Type)
}
}
@ -264,22 +315,26 @@ func (e *EvalResult) And(other *EvalResult) (*EvalResult, error) {
if e.Type != other.Type {
return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type)
}
switch e.Type {
case Boolean:
left, err := e.GetBool()
if err != nil {
return nil, err
}
right, err := other.GetBool()
if err != nil {
return nil, err
}
return &EvalResult{
Type: Boolean,
Value: left && right,
}, nil
default:
return nil, fmt.Errorf("unsupported type: %s", e.Type)
return nil, fmt.Errorf("unsupported value type: %s", e.Type)
}
}
@ -287,22 +342,25 @@ func (e *EvalResult) Or(other *EvalResult) (*EvalResult, error) {
if e.Type != other.Type {
return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type)
}
switch e.Type {
case Boolean:
left, err := e.GetBool()
if err != nil {
return nil, err
}
right, err := other.GetBool()
if err != nil {
return nil, err
}
return &EvalResult{
Type: Boolean,
Value: left || right,
}, nil
default:
return nil, fmt.Errorf("unsupported type: %s", e.Type)
return nil, fmt.Errorf("unsupported value type: %s", e.Type)
}
}
@ -310,67 +368,52 @@ func (e *EvalResult) Not() (*EvalResult, error) {
if e.Type != Boolean {
return nil, fmt.Errorf("type mismatch: %s", e.Type)
}
boolValue, err := e.GetBool()
if err != nil {
return nil, err
}
return &EvalResult{
Type: Boolean,
Value: !boolValue,
}, nil
}
func (e *EvalResult) Is(other *EvalResult) (*EvalResult, error) {
if e.Type != other.Type {
return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type)
}
switch e.Type {
case Boolean:
left, err := e.GetBool()
if err != nil {
return nil, err
}
right, err := other.GetBool()
if err != nil {
return nil, err
}
return &EvalResult{
Type: Boolean,
Value: left == right,
}, nil
default:
return nil, fmt.Errorf("unsupported type: %s", e.Type)
}
}
type Expr interface {
GetType() ExprType
Eval(variables map[string]map[string]any) (*EvalResult, error)
}
type ConstExpr struct {
Type ExprType `json:"type"`
Value Value `json:"value"`
ValueType ValueType `json:"valueType"`
type ExprValueSelector struct {
Id string `json:"id"`
Name string `json:"name"`
Type ExprValueType `json:"type"`
}
func (c ConstExpr) GetType() ExprType { return c.Type }
type ConstantExpr struct {
Type ExprType `json:"type"`
Value string `json:"value"`
ValueType ExprValueType `json:"valueType"`
}
func (c ConstExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) {
func (c ConstantExpr) GetType() ExprType { return c.Type }
func (c ConstantExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) {
return &EvalResult{
Type: c.ValueType,
Value: c.Value,
}, nil
}
type VarExpr struct {
type VariantExpr struct {
Type ExprType `json:"type"`
Selector WorkflowNodeIOValueSelector `json:"selector"`
Selector ExprValueSelector `json:"selector"`
}
func (v VarExpr) GetType() ExprType { return v.Type }
func (v VariantExpr) GetType() ExprType { return v.Type }
func (v VarExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) {
func (v VariantExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) {
if v.Selector.Id == "" {
return nil, fmt.Errorf("node id is empty")
}
@ -391,16 +434,16 @@ func (v VarExpr) Eval(variables map[string]map[string]any) (*EvalResult, error)
}, nil
}
type CompareExpr struct {
type ComparisonExpr struct {
Type ExprType `json:"type"` // compare
Op ComparisonOperator `json:"op"`
Operator ExprComparisonOperator `json:"operator"`
Left Expr `json:"left"`
Right Expr `json:"right"`
}
func (c CompareExpr) GetType() ExprType { return c.Type }
func (c ComparisonExpr) GetType() ExprType { return c.Type }
func (c CompareExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) {
func (c ComparisonExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) {
left, err := c.Left.Eval(variables)
if err != nil {
return nil, err
@ -410,7 +453,7 @@ func (c CompareExpr) Eval(variables map[string]map[string]any) (*EvalResult, err
return nil, err
}
switch c.Op {
switch c.Operator {
case GreaterThan:
return left.GreaterThan(right)
case LessThan:
@ -423,16 +466,14 @@ func (c CompareExpr) Eval(variables map[string]map[string]any) (*EvalResult, err
return left.Equal(right)
case NotEqual:
return left.NotEqual(right)
case Is:
return left.Is(right)
default:
return nil, fmt.Errorf("unknown operator: %s", c.Op)
return nil, fmt.Errorf("unknown expression operator: %s", c.Operator)
}
}
type LogicalExpr struct {
Type ExprType `json:"type"` // logical
Op LogicalOperator `json:"op"`
Operator ExprLogicalOperator `json:"operator"`
Left Expr `json:"left"`
Right Expr `json:"right"`
}
@ -449,13 +490,13 @@ func (l LogicalExpr) Eval(variables map[string]map[string]any) (*EvalResult, err
return nil, err
}
switch l.Op {
switch l.Operator {
case And:
return left.And(right)
case Or:
return left.Or(right)
default:
return nil, fmt.Errorf("unknown operator: %s", l.Op)
return nil, fmt.Errorf("unknown expression operator: %s", l.Operator)
}
}
@ -489,24 +530,24 @@ func UnmarshalExpr(data []byte) (Expr, error) {
}
switch typ.Type {
case ConstExprType:
var e ConstExpr
case ConstantExprType:
var e ConstantExpr
if err := json.Unmarshal(data, &e); err != nil {
return nil, err
}
return e, nil
case VarExprType:
var e VarExpr
case VariantExprType:
var e VariantExpr
if err := json.Unmarshal(data, &e); err != nil {
return nil, err
}
return e, nil
case CompareExprType:
var e CompareExprRaw
case ComparisonExprType:
var e ComparisonExprRaw
if err := json.Unmarshal(data, &e); err != nil {
return nil, err
}
return e.ToCompareExpr()
return e.ToComparisonExpr()
case LogicalExprType:
var e LogicalExprRaw
if err := json.Unmarshal(data, &e); err != nil {
@ -520,29 +561,29 @@ func UnmarshalExpr(data []byte) (Expr, error) {
}
return e.ToNotExpr()
default:
return nil, fmt.Errorf("unknown expr type: %s", typ.Type)
return nil, fmt.Errorf("unknown expression type: %s", typ.Type)
}
}
type CompareExprRaw struct {
type ComparisonExprRaw struct {
Type ExprType `json:"type"`
Op ComparisonOperator `json:"op"`
Operator ExprComparisonOperator `json:"operator"`
Left json.RawMessage `json:"left"`
Right json.RawMessage `json:"right"`
}
func (r CompareExprRaw) ToCompareExpr() (CompareExpr, error) {
func (r ComparisonExprRaw) ToComparisonExpr() (ComparisonExpr, error) {
leftExpr, err := UnmarshalExpr(r.Left)
if err != nil {
return CompareExpr{}, err
return ComparisonExpr{}, err
}
rightExpr, err := UnmarshalExpr(r.Right)
if err != nil {
return CompareExpr{}, err
return ComparisonExpr{}, err
}
return CompareExpr{
return ComparisonExpr{
Type: r.Type,
Op: r.Op,
Operator: r.Operator,
Left: leftExpr,
Right: rightExpr,
}, nil
@ -550,7 +591,7 @@ func (r CompareExprRaw) ToCompareExpr() (CompareExpr, error) {
type LogicalExprRaw struct {
Type ExprType `json:"type"`
Op LogicalOperator `json:"op"`
Operator ExprLogicalOperator `json:"operator"`
Left json.RawMessage `json:"left"`
Right json.RawMessage `json:"right"`
}
@ -566,7 +607,7 @@ func (r LogicalExprRaw) ToLogicalExpr() (LogicalExpr, error) {
}
return LogicalExpr{
Type: r.Type,
Op: r.Op,
Operator: r.Operator,
Left: left,
Right: right,
}, nil

View File

@ -1,4 +1,4 @@
package domain
package expr
import (
"testing"
@ -7,15 +7,15 @@ import (
func TestLogicalEval(t *testing.T) {
// 测试逻辑表达式 and
logicalExpr := LogicalExpr{
Left: ConstExpr{
Left: ConstantExpr{
Type: "const",
Value: true,
Value: "true",
ValueType: "boolean",
},
Op: And,
Right: ConstExpr{
Operator: And,
Right: ConstantExpr{
Type: "const",
Value: true,
Value: "true",
ValueType: "boolean",
},
}
@ -29,15 +29,15 @@ func TestLogicalEval(t *testing.T) {
// 测试逻辑表达式 or
orExpr := LogicalExpr{
Left: ConstExpr{
Left: ConstantExpr{
Type: "const",
Value: true,
Value: "true",
ValueType: "boolean",
},
Op: Or,
Right: ConstExpr{
Operator: Or,
Right: ConstantExpr{
Type: "const",
Value: true,
Value: "true",
ValueType: "boolean",
},
}
@ -63,7 +63,7 @@ func TestUnmarshalExpr(t *testing.T) {
{
name: "test1",
args: args{
data: []byte(`{"left":{"left":{"selector":{"id":"ODnYSOXB6HQP2_vz6JcZE","name":"certificate.validated","type":"boolean"},"type":"var"},"op":"is","right":{"type":"const","value":true,"valueType":"boolean"},"type":"compare"},"op":"and","right":{"left":{"selector":{"id":"ODnYSOXB6HQP2_vz6JcZE","name":"certificate.daysLeft","type":"number"},"type":"var"},"op":"==","right":{"type":"const","value":2,"valueType":"number"},"type":"compare"},"type":"logical"}`),
data: []byte(`{"left":{"left":{"selector":{"id":"ODnYSOXB6HQP2_vz6JcZE","name":"certificate.validity","type":"boolean"},"type":"var"},"operator":"is","right":{"type":"const","value":true,"valueType":"boolean"},"type":"comparison"},"operator":"and","right":{"left":{"selector":{"id":"ODnYSOXB6HQP2_vz6JcZE","name":"certificate.daysLeft","type":"number"},"type":"var"},"operator":"eq","right":{"type":"const","value":2,"valueType":"number"},"type":"comparison"},"type":"logical"}`),
},
},
}
@ -98,11 +98,11 @@ func TestExpr_Eval(t *testing.T) {
args: args{
variables: map[string]map[string]any{
"ODnYSOXB6HQP2_vz6JcZE": {
"certificate.validated": true,
"certificate.validity": true,
"certificate.daysLeft": 2,
},
},
data: []byte(`{"left":{"left":{"selector":{"id":"ODnYSOXB6HQP2_vz6JcZE","name":"certificate.validated","type":"boolean"},"type":"var"},"op":"is","right":{"type":"const","value":true,"valueType":"boolean"},"type":"compare"},"op":"and","right":{"left":{"selector":{"id":"ODnYSOXB6HQP2_vz6JcZE","name":"certificate.daysLeft","type":"number"},"type":"var"},"op":"==","right":{"type":"const","value":2,"valueType":"number"},"type":"compare"},"type":"logical"}`),
data: []byte(`{"left":{"left":{"selector":{"id":"ODnYSOXB6HQP2_vz6JcZE","name":"certificate.validity","type":"boolean"},"type":"var"},"operator":"is","right":{"type":"const","value":true,"valueType":"boolean"},"type":"comparison"},"operator":"and","right":{"left":{"selector":{"id":"ODnYSOXB6HQP2_vz6JcZE","name":"certificate.daysLeft","type":"number"},"type":"var"},"operator":"eq","right":{"type":"const","value":2,"valueType":"number"},"type":"comparison"},"type":"logical"}`),
},
},
}

View File

@ -4,6 +4,7 @@ import (
"encoding/json"
"time"
"github.com/usual2970/certimate/internal/domain/expr"
maputil "github.com/usual2970/certimate/internal/pkg/utils/map"
)
@ -114,7 +115,7 @@ type WorkflowNodeConfigForNotify struct {
}
type WorkflowNodeConfigForCondition struct {
Expression Expr `json:"expression"` // 条件表达式
Expression expr.Expr `json:"expression"` // 条件表达式
}
func (n *WorkflowNode) GetConfigForApply() WorkflowNodeConfigForApply {
@ -183,9 +184,8 @@ func (n *WorkflowNode) GetConfigForCondition() WorkflowNodeConfigForCondition {
return WorkflowNodeConfigForCondition{}
}
raw, _ := json.Marshal(expression)
expr, err := UnmarshalExpr([]byte(raw))
exprRaw, _ := json.Marshal(expression)
expr, err := expr.UnmarshalExpr([]byte(exprRaw))
if err != nil {
return WorkflowNodeConfigForCondition{}
}
@ -204,10 +204,6 @@ type WorkflowNodeIO struct {
ValueSelector WorkflowNodeIOValueSelector `json:"valueSelector"`
}
type WorkflowNodeIOValueSelector struct {
Id string `json:"id"`
Name string `json:"name"`
Type ValueType `json:"type"`
}
type WorkflowNodeIOValueSelector = expr.ExprValueSelector
const WorkflowNodeIONameCertificate string = "certificate"

View File

@ -29,18 +29,18 @@ func NewWithWorkflowNode(config NotifierWithWorkflowNodeConfig) (Notifier, error
return nil, fmt.Errorf("node type is not '%s'", string(domain.WorkflowNodeTypeNotify))
}
nodeConfig := config.Node.GetConfigForNotify()
nodeCfg := config.Node.GetConfigForNotify()
options := &notifierProviderOptions{
Provider: domain.NotificationProviderType(nodeConfig.Provider),
Provider: domain.NotificationProviderType(nodeCfg.Provider),
ProviderAccessConfig: make(map[string]any),
ProviderServiceConfig: nodeConfig.ProviderConfig,
ProviderServiceConfig: nodeCfg.ProviderConfig,
}
accessRepo := repository.NewAccessRepository()
if nodeConfig.ProviderAccessId != "" {
access, err := accessRepo.GetById(context.Background(), nodeConfig.ProviderAccessId)
if nodeCfg.ProviderAccessId != "" {
access, err := accessRepo.GetById(context.Background(), nodeCfg.ProviderAccessId)
if err != nil {
return nil, fmt.Errorf("failed to get access #%s record: %w", nodeConfig.ProviderAccessId, err)
return nil, fmt.Errorf("failed to get access #%s record: %w", nodeCfg.ProviderAccessId, err)
} else {
options.ProviderAccessConfig = access.Config
}

View File

@ -29,6 +29,7 @@ func NewChallengeProvider(config *ChallengeProviderConfig) (challenge.Provider,
providerConfig.APIKey = config.ApiKey
if config.AllowInsecureConnections {
providerConfig.HTTPClient.Transport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},

View File

@ -13,6 +13,7 @@ import (
"github.com/luthermonson/go-proxmox"
"github.com/usual2970/certimate/internal/pkg/core/deployer"
httputil "github.com/usual2970/certimate/internal/pkg/utils/http"
)
type DeployerConfig struct {
@ -101,15 +102,16 @@ func createSdkClient(serverUrl, apiToken, apiTokenSecret string, skipTlsVerify b
}
httpClient := &http.Client{
Transport: http.DefaultTransport,
Transport: httputil.NewDefaultTransport(),
Timeout: http.DefaultClient.Timeout,
}
if skipTlsVerify {
httpClient.Transport = &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
transport := httputil.NewDefaultTransport()
if transport.TLSClientConfig == nil {
transport.TLSClientConfig = &tls.Config{}
}
transport.TLSClientConfig.InsecureSkipVerify = true
httpClient.Transport = transport
}
client := proxmox.NewClient(
strings.TrimRight(serverUrl, "/")+"/api2/json",

View File

@ -65,7 +65,7 @@ func (u *UploaderProvider) Upload(ctx context.Context, certPEM string, privkeyPE
}
// 查询证书列表,避免重复上传
// REF: https://www.wangsu.com/document/api-doc/26426
// REF: https://www.wangsu.com/document/api-doc/22675?productCode=certificatemanagement
listCertificatesResp, err := u.sdkClient.ListCertificates()
u.logger.Debug("sdk request 'certificatemanagement.ListCertificates'", slog.Any("response", listCertificatesResp))
if err != nil {

View File

@ -0,0 +1,33 @@
package httputil
import (
"net"
"net/http"
"time"
)
// 创建并返回一个 [http.DefaultTransport] 对象副本。
//
// 出参:
// - transport: [http.DefaultTransport] 对象副本。
func NewDefaultTransport() *http.Transport {
if http.DefaultTransport != nil {
if t, ok := http.DefaultTransport.(*http.Transport); ok {
return t.Clone()
}
}
return &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
DualStack: true,
}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
}

View File

@ -3,6 +3,7 @@ package nodeprocessor
import (
"context"
"fmt"
"strconv"
"time"
"golang.org/x/exp/maps"
@ -108,15 +109,15 @@ func (n *applyNode) Process(ctx context.Context) error {
}
}
// 添加中间结果
n.outputs[outputCertificateValidatedKey] = "true"
n.outputs[outputCertificateDaysLeftKey] = fmt.Sprintf("%d", int(time.Until(certificate.ExpireAt).Hours()/24))
// 记录中间结果
n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(true)
n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(time.Until(certificate.ExpireAt).Hours()/24), 10)
n.logger.Info("application completed")
return nil
}
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 {
// 比较和上次申请时的关键配置(即影响证书签发的)参数是否一致
currentNodeConfig := n.node.GetConfigForApply()
@ -154,9 +155,12 @@ func (n *applyNode) checkCanSkip(ctx context.Context, lastOutput *domain.Workflo
renewalInterval := time.Duration(currentNodeConfig.SkipBeforeExpiryDays) * time.Hour * 24
expirationTime := time.Until(lastCertificate.ExpireAt)
if expirationTime > renewalInterval {
n.outputs[outputCertificateValidatedKey] = "true"
n.outputs[outputCertificateDaysLeftKey] = fmt.Sprintf("%d", int(expirationTime.Hours()/24))
return true, fmt.Sprintf("the certificate has already been issued (expires in %d day(s), next renewal in %d day(s))", int(expirationTime.Hours()/24), currentNodeConfig.SkipBeforeExpiryDays)
daysLeft := int(expirationTime.Hours() / 24)
// TODO: 优化此处逻辑,[checkCanSkip] 方法不应该修改中间结果,违背单一职责
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)
}
}
}

View File

@ -3,8 +3,10 @@ package nodeprocessor
import (
"context"
"errors"
"fmt"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/domain/expr"
)
type conditionNode struct {
@ -22,30 +24,29 @@ func NewConditionNode(node *domain.WorkflowNode) *conditionNode {
}
func (n *conditionNode) Process(ctx context.Context) error {
n.logger.Info("enter condition node: " + n.node.Name)
nodeConfig := n.node.GetConfigForCondition()
if nodeConfig.Expression == nil {
n.logger.Info("no condition found, continue to next node")
nodeCfg := n.node.GetConfigForCondition()
if nodeCfg.Expression == nil {
n.logger.Info("without any conditions, enter this branch")
return nil
}
rs, err := n.eval(ctx, nodeConfig.Expression)
rs, err := n.evalExpr(ctx, nodeCfg.Expression)
if err != nil {
n.logger.Warn("failed to eval expression: " + err.Error())
n.logger.Warn(fmt.Sprintf("failed to eval condition expression: %w", err))
return err
}
if rs.Value == false {
n.logger.Info("condition not met, skip this branch")
return errors.New("condition not met")
return errors.New("condition not met") // TODO: 错误处理
} else {
n.logger.Info("condition met, enter this branch")
}
n.logger.Info("condition met, continue to next node")
return nil
}
func (n *conditionNode) eval(ctx context.Context, expression domain.Expr) (*domain.EvalResult, error) {
func (n *conditionNode) evalExpr(ctx context.Context, expression expr.Expr) (*expr.EvalResult, error) {
variables := GetNodeOutputs(ctx)
return expression.Eval(variables)
}

View File

@ -1,6 +1,6 @@
package nodeprocessor
const (
outputCertificateValidatedKey = "certificate.validated"
outputCertificateDaysLeftKey = "certificate.daysLeft"
outputKeyForCertificateValidity = "certificate.validity"
outputKeyForCertificateDaysLeft = "certificate.daysLeft"
)

View File

@ -35,7 +35,8 @@ func AddNodeOutput(ctx context.Context, nodeId string, output map[string]any) co
container.Lock()
defer container.Unlock()
// 创建输出的深拷贝以避免后续修改
// 创建输出的深拷贝
// TODO: 暂时使用浅拷贝,等后续值类型扩充后修改
outputCopy := make(map[string]any, len(output))
for k, v := range output {
outputCopy[k] = v
@ -90,6 +91,7 @@ func GetNodeOutputs(ctx context.Context) map[string]map[string]any {
defer container.RUnlock()
// 创建所有输出的深拷贝
// TODO: 暂时使用浅拷贝,等后续值类型扩充后修改
allOutputs := make(map[string]map[string]any, len(container.outputs))
for nodeId, output := range container.outputs {
nodeCopy := make(map[string]any, len(output))

View File

@ -42,8 +42,9 @@ func (n *deployNode) Process(ctx context.Context) error {
}
// 获取前序节点输出证书
const DELIMITER = "#"
previousNodeOutputCertificateSource := n.node.GetConfigForDeploy().Certificate
previousNodeOutputCertificateSourceSlice := strings.Split(previousNodeOutputCertificateSource, "#")
previousNodeOutputCertificateSourceSlice := strings.Split(previousNodeOutputCertificateSource, DELIMITER)
if len(previousNodeOutputCertificateSourceSlice) != 2 {
n.logger.Warn("invalid certificate source", slog.String("certificate.source", previousNodeOutputCertificateSource))
return fmt.Errorf("invalid certificate source: %s", previousNodeOutputCertificateSource)
@ -99,7 +100,7 @@ func (n *deployNode) Process(ctx context.Context) error {
return nil
}
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 {
// 比较和上次部署时的关键配置(即影响证书部署的)参数是否一致
currentNodeConfig := n.node.GetConfigForDeploy()

View File

@ -6,13 +6,13 @@ import (
"crypto/x509"
"fmt"
"math"
"net"
"net/http"
"strconv"
"strings"
"time"
"github.com/usual2970/certimate/internal/domain"
httputil "github.com/usual2970/certimate/internal/pkg/utils/http"
)
type monitorNode struct {
@ -32,23 +32,23 @@ func NewMonitorNode(node *domain.WorkflowNode) *monitorNode {
func (n *monitorNode) Process(ctx context.Context) error {
n.logger.Info("ready to monitor certificate ...")
nodeConfig := n.node.GetConfigForMonitor()
nodeCfg := n.node.GetConfigForMonitor()
targetAddr := fmt.Sprintf("%s:%d", nodeConfig.Host, nodeConfig.Port)
if nodeConfig.Port == 0 {
targetAddr = fmt.Sprintf("%s:443", nodeConfig.Host)
targetAddr := fmt.Sprintf("%s:%d", nodeCfg.Host, nodeCfg.Port)
if nodeCfg.Port == 0 {
targetAddr = fmt.Sprintf("%s:443", nodeCfg.Host)
}
targetDomain := nodeConfig.Domain
targetDomain := nodeCfg.Domain
if targetDomain == "" {
targetDomain = nodeConfig.Host
targetDomain = nodeCfg.Host
}
n.logger.Info(fmt.Sprintf("retrieving certificate at %s (domain: %s)", targetAddr, targetDomain))
const MAX_ATTEMPTS = 3
const RETRY_INTERVAL = 2 * time.Second
var cert *x509.Certificate
var certs []*x509.Certificate
var err error
for attempt := 0; attempt < MAX_ATTEMPTS; attempt++ {
if attempt > 0 {
@ -61,7 +61,7 @@ func (n *monitorNode) Process(ctx context.Context) error {
}
}
cert, err = n.tryRetrieveCert(ctx, targetAddr, targetDomain, nodeConfig.RequestPath)
certs, err = n.tryRetrievePeerCertificates(ctx, targetAddr, targetDomain, nodeCfg.RequestPath)
if err == nil {
break
}
@ -71,15 +71,13 @@ func (n *monitorNode) Process(ctx context.Context) error {
n.logger.Warn("failed to monitor certificate")
return err
} else {
if cert == nil {
if len(certs) == 0 {
n.logger.Warn("no ssl certificates retrieved in http response")
outputs := map[string]any{
outputCertificateValidatedKey: strconv.FormatBool(false),
outputCertificateDaysLeftKey: strconv.FormatInt(0, 10),
}
n.setOutputs(outputs)
n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(false)
n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(0, 10)
} else {
cert := certs[0] // 只取证书链中的第一个证书,即服务器证书
n.logger.Info(fmt.Sprintf("ssl certificate retrieved (serial='%s', subject='%s', issuer='%s', not_before='%s', not_after='%s', sans='%s')",
cert.SerialNumber, cert.Subject.String(), cert.Issuer.String(),
cert.NotBefore.Format(time.RFC3339), cert.NotAfter.Format(time.RFC3339),
@ -95,11 +93,8 @@ func (n *monitorNode) Process(ctx context.Context) error {
validated := isCertPeriodValid && isCertHostMatched
daysLeft := int(math.Floor(cert.NotAfter.Sub(now).Hours() / 24))
outputs := map[string]any{
outputCertificateValidatedKey: strconv.FormatBool(validated),
outputCertificateDaysLeftKey: strconv.FormatInt(int64(daysLeft), 10),
}
n.setOutputs(outputs)
n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(validated)
n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(daysLeft), 10)
if validated {
n.logger.Info(fmt.Sprintf("the certificate is valid, and will expire in %d day(s)", daysLeft))
@ -113,52 +108,40 @@ func (n *monitorNode) Process(ctx context.Context) error {
return nil
}
func (n *monitorNode) tryRetrieveCert(ctx context.Context, addr, domain, requestPath string) (_cert *x509.Certificate, _err error) {
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
}).DialContext,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
ForceAttemptHTTP2: false,
DisableKeepAlives: true,
Proxy: http.ProxyFromEnvironment,
func (n *monitorNode) tryRetrievePeerCertificates(ctx context.Context, addr, domain, requestPath string) ([]*x509.Certificate, error) {
transport := httputil.NewDefaultTransport()
if transport.TLSClientConfig == nil {
transport.TLSClientConfig = &tls.Config{}
}
transport.TLSClientConfig.InsecureSkipVerify = true
client := &http.Client{
Transport: transport,
Timeout: 15 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Timeout: 30 * time.Second,
Transport: transport,
}
url := fmt.Sprintf("https://%s/%s", addr, strings.TrimLeft(requestPath, "/"))
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
if err != nil {
_err = fmt.Errorf("failed to create http request: %w", err)
n.logger.Warn(fmt.Sprintf("failed to create http request: %w", err))
return nil, _err
err = fmt.Errorf("failed to create http request: %w", err)
n.logger.Warn(err.Error())
return nil, err
}
req.Header.Set("User-Agent", "certimate")
resp, err := client.Do(req)
if err != nil {
_err = fmt.Errorf("failed to send http request: %w", err)
n.logger.Warn(fmt.Sprintf("failed to send http request: %w", err))
return nil, _err
err = fmt.Errorf("failed to send http request: %w", err)
n.logger.Warn(err.Error())
return nil, err
}
defer resp.Body.Close()
if resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 {
return nil, _err
return make([]*x509.Certificate, 0), nil
}
_cert = resp.TLS.PeerCertificates[0]
return _cert, nil
}
func (n *monitorNode) setOutputs(outputs map[string]any) {
n.outputs = outputs
return resp.TLS.PeerCertificates, nil
}

View File

@ -30,9 +30,9 @@ func NewNotifyNode(node *domain.WorkflowNode) *notifyNode {
func (n *notifyNode) Process(ctx context.Context) error {
n.logger.Info("ready to send notification ...")
nodeConfig := n.node.GetConfigForNotify()
nodeCfg := n.node.GetConfigForNotify()
if nodeConfig.Provider == "" {
if nodeCfg.Provider == "" {
// Deprecated: v0.4.x 将废弃
// 兼容旧版本的通知渠道
n.logger.Warn("WARNING! you are using the notification channel from global settings, which will be deprecated in the future")
@ -44,14 +44,14 @@ func (n *notifyNode) Process(ctx context.Context) error {
}
// 获取通知渠道
channelConfig, err := settings.GetNotifyChannelConfig(nodeConfig.Channel)
channelConfig, err := settings.GetNotifyChannelConfig(nodeCfg.Channel)
if err != nil {
return err
}
// 发送通知
if err := notify.SendToChannel(nodeConfig.Subject, nodeConfig.Message, nodeConfig.Channel, channelConfig); err != nil {
n.logger.Warn("failed to send notification", slog.String("channel", nodeConfig.Channel))
if err := notify.SendToChannel(nodeCfg.Subject, nodeCfg.Message, nodeCfg.Channel, channelConfig); err != nil {
n.logger.Warn("failed to send notification", slog.String("channel", nodeCfg.Channel))
return err
}
@ -63,8 +63,8 @@ func (n *notifyNode) Process(ctx context.Context) error {
deployer, err := notify.NewWithWorkflowNode(notify.NotifierWithWorkflowNodeConfig{
Node: n.node,
Logger: n.logger,
Subject: nodeConfig.Subject,
Message: nodeConfig.Message,
Subject: nodeCfg.Subject,
Message: nodeCfg.Message,
})
if err != nil {
n.logger.Warn("failed to create notifier provider")

View File

@ -3,6 +3,7 @@ package nodeprocessor
import (
"context"
"fmt"
"strconv"
"strings"
"time"
@ -33,7 +34,7 @@ func NewUploadNode(node *domain.WorkflowNode) *uploadNode {
func (n *uploadNode) Process(ctx context.Context) error {
n.logger.Info("ready to upload certiticate ...")
nodeConfig := n.node.GetConfigForUpload()
nodeCfg := n.node.GetConfigForUpload()
// 查询上次执行结果
lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id)
@ -53,7 +54,7 @@ func (n *uploadNode) Process(ctx context.Context) error {
certificate := &domain.Certificate{
Source: domain.CertificateSourceTypeUpload,
}
certificate.PopulateFromPEM(nodeConfig.Certificate, nodeConfig.PrivateKey)
certificate.PopulateFromPEM(nodeCfg.Certificate, nodeCfg.PrivateKey)
// 保存执行结果
output := &domain.WorkflowOutput{
@ -69,15 +70,15 @@ func (n *uploadNode) Process(ctx context.Context) error {
return err
}
n.outputs[outputCertificateValidatedKey] = "true"
n.outputs[outputCertificateDaysLeftKey] = fmt.Sprintf("%d", int(time.Until(certificate.ExpireAt).Hours()/24))
// 记录中间结果
n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(true)
n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(time.Until(certificate.ExpireAt).Hours()/24), 10)
n.logger.Info("uploading completed")
return nil
}
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 {
// 比较和上次上传时的关键配置(即影响证书上传的)参数是否一致
currentNodeConfig := n.node.GetConfigForUpload()
@ -91,8 +92,10 @@ func (n *uploadNode) checkCanSkip(ctx context.Context, lastOutput *domain.Workfl
lastCertificate, _ := n.certRepo.GetByWorkflowRunId(ctx, lastOutput.RunId)
if lastCertificate != nil {
n.outputs[outputCertificateValidatedKey] = "true"
n.outputs[outputCertificateDaysLeftKey] = fmt.Sprintf("%d", int(time.Until(lastCertificate.ExpireAt).Hours()/24))
daysLeft := int(time.Until(lastCertificate.ExpireAt).Hours() / 24)
n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(daysLeft > 0)
n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(daysLeft), 10)
return true, "the certificate has already been uploaded"
}
}

View File

@ -26,9 +26,7 @@ func main() {
app := app.GetApp().(*pocketbase.PocketBase)
var flagHttp string
var flagDir string
flag.StringVar(&flagHttp, "http", "127.0.0.1:8090", "HTTP server address")
flag.StringVar(&flagDir, "dir", "/pb_data/database", "Pocketbase data directory")
if len(os.Args) < 2 {
slog.Error("[CERTIMATE] missing exec args")
os.Exit(1)
@ -59,14 +57,17 @@ func main() {
Priority: 999,
})
app.OnServe().BindFunc(func(e *core.ServeEvent) error {
slog.Info("[CERTIMATE] Visit the website: http://" + flagHttp)
return e.Next()
})
app.OnTerminate().BindFunc(func(e *core.TerminateEvent) error {
routes.Unregister()
slog.Info("[CERTIMATE] Exit!")
return e.Next()
})
slog.Info("[CERTIMATE] Visit the website: http://" + flagHttp)
if err := app.Start(); err != nil {
slog.Error("[CERTIMATE] Start failed.", "err", err)
}

View File

@ -152,10 +152,10 @@ const MultipleInput = ({
value={element}
onBlur={() => handleInputBlur(index)}
onChange={(val) => handleChange(index, val)}
onClickAdd={() => handleClickAdd(index)}
onClickDown={() => handleClickDown(index)}
onClickUp={() => handleClickUp(index)}
onClickRemove={() => handleClickRemove(index)}
onEntryAdd={() => handleClickAdd(index)}
onEntryDown={() => handleClickDown(index)}
onEntryUp={() => handleClickUp(index)}
onEntryRemove={() => handleClickRemove(index)}
/>
);
})}
@ -174,10 +174,10 @@ type MultipleInputItemProps = Omit<
defaultValue?: string;
value?: string;
onChange?: (value: string) => void;
onClickAdd?: () => void;
onClickDown?: () => void;
onClickUp?: () => void;
onClickRemove?: () => void;
onEntryAdd?: () => void;
onEntryDown?: () => void;
onEntryUp?: () => void;
onEntryRemove?: () => void;
};
type MultipleInputItemInstance = {
@ -197,10 +197,10 @@ const MultipleInputItem = forwardRef<MultipleInputItemInstance, MultipleInputIte
disabled,
showSortButton,
size,
onClickAdd,
onClickDown,
onClickUp,
onClickRemove,
onEntryAdd,
onEntryDown,
onEntryUp,
onEntryRemove,
...props
}: MultipleInputItemProps,
ref
@ -216,18 +216,18 @@ const MultipleInputItem = forwardRef<MultipleInputItemInstance, MultipleInputIte
const upBtn = useMemo(() => {
if (!showSortButton) return null;
return <Button icon={<ArrowUpOutlinedIcon />} color="default" disabled={disabled || !allowUp} type="text" onClick={onClickUp} />;
}, [allowUp, disabled, showSortButton, onClickUp]);
return <Button icon={<ArrowUpOutlinedIcon />} color="default" disabled={disabled || !allowUp} type="text" onClick={onEntryUp} />;
}, [allowUp, disabled, showSortButton, onEntryUp]);
const downBtn = useMemo(() => {
if (!showSortButton) return null;
return <Button icon={<ArrowDownOutlinedIcon />} color="default" disabled={disabled || !allowDown} type="text" onClick={onClickDown} />;
}, [allowDown, disabled, showSortButton, onClickDown]);
return <Button icon={<ArrowDownOutlinedIcon />} color="default" disabled={disabled || !allowDown} type="text" onClick={onEntryDown} />;
}, [allowDown, disabled, showSortButton, onEntryDown]);
const removeBtn = useMemo(() => {
return <Button icon={<MinusOutlinedIcon />} color="default" disabled={disabled || !allowRemove} type="text" onClick={onClickRemove} />;
}, [allowRemove, disabled, onClickRemove]);
return <Button icon={<MinusOutlinedIcon />} color="default" disabled={disabled || !allowRemove} type="text" onClick={onEntryRemove} />;
}, [allowRemove, disabled, onEntryRemove]);
const addBtn = useMemo(() => {
return <Button icon={<PlusOutlinedIcon />} color="default" disabled={disabled || !allowAdd} type="text" onClick={onClickAdd} />;
}, [allowAdd, disabled, onClickAdd]);
return <Button icon={<PlusOutlinedIcon />} color="default" disabled={disabled || !allowAdd} type="text" onClick={onEntryAdd} />;
}, [allowAdd, disabled, onEntryAdd]);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);

View File

@ -15,24 +15,24 @@ type SplitOptions = {
export type MultipleSplitValueInputProps = Omit<InputProps, "count" | "defaultValue" | "showCount" | "value" | "onChange"> & {
defaultValue?: string;
delimiter?: string;
maxCount?: number;
minCount?: number;
modalTitle?: string;
modalWidth?: number;
placeholderInModal?: string;
showSortButton?: boolean;
separator?: string;
splitOptions?: SplitOptions;
value?: string[];
onChange?: (value: string) => void;
};
const DEFAULT_DELIMITER = ";";
const DEFAULT_SEPARATOR = ";";
const MultipleSplitValueInput = ({
className,
style,
delimiter = DEFAULT_DELIMITER,
separator: delimiter = DEFAULT_SEPARATOR,
disabled,
maxCount,
minCount,

View File

@ -265,7 +265,7 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues
})}
/>
) : null}
<Button type="dashed" className="w-full" icon={<PlusOutlined />} onClick={() => add()}>
<Button className="w-full" type="dashed" icon={<PlusOutlined />} onClick={() => add()}>
{t("access.form.ssh_jump_servers.add")}
</Button>
</Space>

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Avatar, Select, type SelectProps, Space, Typography } from "antd";
import { Avatar, Select, type SelectProps, Space, Typography, theme } from "antd";
import { type AccessModel } from "@/domain/access";
import { accessProvidersMap } from "@/domain/provider";
@ -14,6 +14,8 @@ export type AccessTypeSelectProps = Omit<
};
const AccessSelect = ({ filter, ...props }: AccessTypeSelectProps) => {
const { token: themeToken } = theme.useToken();
const { accesses, loadedAtOnce, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "loadedAtOnce", "fetchAccesses"]));
useEffect(() => {
fetchAccesses();
@ -65,12 +67,12 @@ const AccessSelect = ({ filter, ...props }: AccessTypeSelectProps) => {
const value = inputValue.toLowerCase();
return option.label.toLowerCase().includes(value);
}}
labelRender={({ label, value }) => {
if (label) {
labelRender={({ value }) => {
if (value != null) {
return renderOption(value as string);
}
return <Typography.Text type="secondary">{props.placeholder}</Typography.Text>;
return <span style={{ color: themeToken.colorTextPlaceholder }}>{props.placeholder}</span>;
}}
loading={!loadedAtOnce}
options={options}

View File

@ -1,6 +1,6 @@
import { memo, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Avatar, Select, type SelectProps, Space, Typography } from "antd";
import { Avatar, Select, type SelectProps, Space, Typography, theme } from "antd";
import { type ACMEDns01Provider, acmeDns01ProvidersMap } from "@/domain/provider";
@ -14,6 +14,8 @@ export type ACMEDns01ProviderSelectProps = Omit<
const ACMEDns01ProviderSelect = ({ filter, ...props }: ACMEDns01ProviderSelectProps) => {
const { t } = useTranslation();
const { token: themeToken } = theme.useToken();
const [options, setOptions] = useState<Array<{ key: string; value: string; label: string; data: ACMEDns01Provider }>>([]);
useEffect(() => {
const allItems = Array.from(acmeDns01ProvidersMap.values());
@ -49,12 +51,12 @@ const ACMEDns01ProviderSelect = ({ filter, ...props }: ACMEDns01ProviderSelectPr
const value = inputValue.toLowerCase();
return option.value.toLowerCase().includes(value) || option.label.toLowerCase().includes(value);
}}
labelRender={({ label, value }) => {
if (!label) {
return <Typography.Text type="secondary">{props.placeholder}</Typography.Text>;
labelRender={({ value }) => {
if (value != null) {
return renderOption(value as string);
}
return renderOption(value as string);
return <span style={{ color: themeToken.colorTextPlaceholder }}>{props.placeholder}</span>;
}}
options={options}
optionFilterProp={undefined}

View File

@ -1,6 +1,6 @@
import { memo, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Avatar, Select, type SelectProps, Space, Tag, Typography } from "antd";
import { Avatar, Select, type SelectProps, Space, Tag, Typography, theme } from "antd";
import Show from "@/components/Show";
import { ACCESS_USAGES, type AccessProvider, type AccessUsageType, accessProvidersMap } from "@/domain/provider";
@ -16,6 +16,8 @@ export type AccessProviderSelectProps = Omit<
const AccessProviderSelect = ({ filter, showOptionTags, ...props }: AccessProviderSelectProps = { showOptionTags: true }) => {
const { t } = useTranslation();
const { token: themeToken } = theme.useToken();
const [options, setOptions] = useState<Array<{ key: string; value: string; label: string; data: AccessProvider }>>([]);
useEffect(() => {
const allItems = Array.from(accessProvidersMap.values());
@ -84,12 +86,12 @@ const AccessProviderSelect = ({ filter, showOptionTags, ...props }: AccessProvid
const value = inputValue.toLowerCase();
return option.value.toLowerCase().includes(value) || option.label.toLowerCase().includes(value);
}}
labelRender={({ label, value }) => {
if (!label) {
return <Typography.Text type="secondary">{props.placeholder}</Typography.Text>;
labelRender={({ value }) => {
if (value != null) {
return renderOption(value as string);
}
return renderOption(value as string);
return <span style={{ color: themeToken.colorTextPlaceholder }}>{props.placeholder}</span>;
}}
options={options}
optionFilterProp={undefined}

View File

@ -1,6 +1,6 @@
import { memo, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Avatar, Select, type SelectProps, Space, Typography } from "antd";
import { Avatar, Select, type SelectProps, Space, Typography, theme } from "antd";
import { type CAProvider, caProvidersMap } from "@/domain/provider";
@ -14,6 +14,8 @@ export type CAProviderSelectProps = Omit<
const CAProviderSelect = ({ filter, ...props }: CAProviderSelectProps) => {
const { t } = useTranslation();
const { token: themeToken } = theme.useToken();
const [options, setOptions] = useState<Array<{ key: string; value: string; label: string; data: CAProvider }>>([]);
useEffect(() => {
const allItems = Array.from(caProvidersMap.values());
@ -65,12 +67,12 @@ const CAProviderSelect = ({ filter, ...props }: CAProviderSelectProps) => {
const value = inputValue.toLowerCase();
return option.value.toLowerCase().includes(value) || option.label.toLowerCase().includes(value);
}}
labelRender={({ label, value }) => {
if (!label) {
return <Typography.Text type="secondary">{props.placeholder || t("provider.default_ca_provider.label")}</Typography.Text>;
labelRender={({ value }) => {
if (value != null) {
return renderOption(value as string);
}
return renderOption(value as string);
return <span style={{ color: themeToken.colorTextPlaceholder }}>{props.placeholder}</span>;
}}
options={options}
optionFilterProp={undefined}

View File

@ -1,6 +1,6 @@
import { memo, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Avatar, Select, type SelectProps, Space, Typography } from "antd";
import { Avatar, Select, type SelectProps, Space, Typography, theme } from "antd";
import { type DeploymentProvider, deploymentProvidersMap } from "@/domain/provider";
@ -14,6 +14,8 @@ export type DeploymentProviderSelectProps = Omit<
const DeploymentProviderSelect = ({ filter, ...props }: DeploymentProviderSelectProps) => {
const { t } = useTranslation();
const { token: themeToken } = theme.useToken();
const [options, setOptions] = useState<Array<{ key: string; value: string; label: string; data: DeploymentProvider }>>([]);
useEffect(() => {
const allItems = Array.from(deploymentProvidersMap.values());
@ -49,12 +51,12 @@ const DeploymentProviderSelect = ({ filter, ...props }: DeploymentProviderSelect
const value = inputValue.toLowerCase();
return option.value.toLowerCase().includes(value) || option.label.toLowerCase().includes(value);
}}
labelRender={({ label, value }) => {
if (!label) {
return <Typography.Text type="secondary">{props.placeholder}</Typography.Text>;
labelRender={({ value }) => {
if (value != null) {
return renderOption(value as string);
}
return renderOption(value as string);
return <span style={{ color: themeToken.colorTextPlaceholder }}>{props.placeholder}</span>;
}}
options={options}
optionFilterProp={undefined}

View File

@ -1,6 +1,6 @@
import { memo, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Avatar, Select, type SelectProps, Space, Typography } from "antd";
import { Avatar, Select, type SelectProps, Space, Typography, theme } from "antd";
import { type NotificationProvider, notificationProvidersMap } from "@/domain/provider";
@ -14,6 +14,8 @@ export type NotificationProviderSelectProps = Omit<
const NotificationProviderSelect = ({ filter, ...props }: NotificationProviderSelectProps) => {
const { t } = useTranslation();
const { token: themeToken } = theme.useToken();
const [options, setOptions] = useState<Array<{ key: string; value: string; label: string; data: NotificationProvider }>>([]);
useEffect(() => {
const allItems = Array.from(notificationProvidersMap.values());
@ -49,12 +51,12 @@ const NotificationProviderSelect = ({ filter, ...props }: NotificationProviderSe
const value = inputValue.toLowerCase();
return option.value.toLowerCase().includes(value) || option.label.toLowerCase().includes(value);
}}
labelRender={({ label, value }) => {
if (!label) {
return <Typography.Text type="secondary">{props.placeholder}</Typography.Text>;
labelRender={({ value }) => {
if (value != null) {
return renderOption(value as string);
}
return renderOption(value as string);
return <span style={{ color: themeToken.colorTextPlaceholder }}>{props.placeholder}</span>;
}}
options={options}
optionFilterProp={undefined}

View File

@ -36,7 +36,7 @@ import { ClientResponseError } from "pocketbase";
import CertificateDetailDrawer from "@/components/certificate/CertificateDetailDrawer";
import Show from "@/components/Show";
import { type CertificateModel } from "@/domain/certificate";
import type { WorkflowLogModel } from "@/domain/workflowLog";
import { type WorkflowLogModel } from "@/domain/workflowLog";
import { WORKFLOW_RUN_STATUSES, type WorkflowRunModel } from "@/domain/workflowRun";
import { useBrowserTheme } from "@/hooks";
import { listByWorkflowRunId as listCertificatesByWorkflowRunId } from "@/repository/certificate";

View File

@ -35,7 +35,14 @@ const AddNode = ({ node, disabled }: AddNodeProps) => {
[WorkflowNodeType.ExecuteResultBranch, "workflow_node.execute_result_branch.label", <SisternodeOutlinedIcon />],
]
.filter(([type]) => {
if (node.type !== WorkflowNodeType.Apply && node.type !== WorkflowNodeType.Deploy && node.type !== WorkflowNodeType.Notify) {
const hasExecuteResult = [
WorkflowNodeType.Apply,
WorkflowNodeType.Upload,
WorkflowNodeType.Monitor,
WorkflowNodeType.Deploy,
WorkflowNodeType.Notify,
].includes(node.type);
if (!hasExecuteResult) {
return type !== WorkflowNodeType.ExecuteResultBranch;
}

View File

@ -38,9 +38,9 @@ const ApplyNode = ({ node, disabled }: ApplyNodeProps) => {
const formRef = useRef<ApplyNodeConfigFormInstance>(null);
const [formPending, setFormPending] = useState(false);
const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForApply;
const [drawerOpen, setDrawerOpen] = useState(false);
const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForApply;
const handleDrawerConfirm = async () => {
setFormPending(true);
@ -74,12 +74,12 @@ const ApplyNode = ({ node, disabled }: ApplyNodeProps) => {
</SharedNode.Block>
<SharedNode.ConfigDrawer
getConfigNewValues={getFormValues}
node={node}
open={drawerOpen}
pending={formPending}
onConfirm={handleDrawerConfirm}
onOpenChange={(open) => setDrawerOpen(open)}
getFormValues={() => formRef.current!.getFieldsValue()}
>
<ApplyNodeConfigForm ref={formRef} disabled={disabled} initialValues={node.config} />
</SharedNode.ConfigDrawer>

View File

@ -56,7 +56,7 @@ export type ApplyNodeConfigFormInstance = {
validateFields: FormInstance<ApplyNodeConfigFormFieldValues>["validateFields"];
};
const MULTIPLE_INPUT_DELIMITER = ";";
const MULTIPLE_INPUT_SEPARATOR = ";";
const initFormModel = (): ApplyNodeConfigFormFieldValues => {
return {
@ -76,7 +76,7 @@ const ApplyNodeConfigForm = forwardRef<ApplyNodeConfigFormInstance, ApplyNodeCon
domains: z.string({ message: t("workflow_node.apply.form.domains.placeholder") }).refine((v) => {
if (!v) return false;
return String(v)
.split(MULTIPLE_INPUT_DELIMITER)
.split(MULTIPLE_INPUT_SEPARATOR)
.every((e) => validDomainName(e, { allowWildcard: true }));
}, t("common.errmsg.domain_invalid")),
contactEmail: z.string({ message: t("workflow_node.apply.form.contact_email.placeholder") }).email(t("common.errmsg.email_invalid")),
@ -106,7 +106,7 @@ const ApplyNodeConfigForm = forwardRef<ApplyNodeConfigFormInstance, ApplyNodeCon
.refine((v) => {
if (!v) return true;
return String(v)
.split(MULTIPLE_INPUT_DELIMITER)
.split(MULTIPLE_INPUT_SEPARATOR)
.every((e) => validIPv4Address(e) || validIPv6Address(e) || validDomainName(e));
}, t("common.errmsg.host_invalid")),
dnsPropagationWait: z.preprocess(

View File

@ -1,17 +1,14 @@
import { memo, useRef, useState } from "react";
import { MoreOutlined as MoreOutlinedIcon } from "@ant-design/icons";
import { FilterFilled as FilterFilledIcon, FilterOutlined as FilterOutlinedIcon, MoreOutlined as MoreOutlinedIcon } from "@ant-design/icons";
import { Button, Card, Popover } from "antd";
import { produce } from "immer";
import type { Expr, WorkflowNodeIoValueType } from "@/domain/workflow";
import { ExprType } from "@/domain/workflow";
import { useZustandShallowSelector } from "@/hooks";
import { useWorkflowStore } from "@/stores/workflow";
import SharedNode, { type SharedNodeProps } from "./_SharedNode";
import AddNode from "./AddNode";
import type { ConditionItem, ConditionNodeConfigFormFieldValues, ConditionNodeConfigFormInstance } from "./ConditionNodeConfigForm";
import ConditionNodeConfigForm from "./ConditionNodeConfigForm";
import ConditionNodeConfigForm, { type ConditionNodeConfigFormFieldValues, type ConditionNodeConfigFormInstance } from "./ConditionNodeConfigForm";
export type ConditionNodeProps = SharedNodeProps & {
branchId: string;
@ -23,55 +20,9 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP
const [formPending, setFormPending] = useState(false);
const formRef = useRef<ConditionNodeConfigFormInstance>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const getFormValues = () => formRef.current!.getFieldsValue() as ConditionNodeConfigFormFieldValues;
// 将表单值转换为表达式结构
const formToExpression = (values: ConditionNodeConfigFormFieldValues): Expr => {
// 创建单个条件的表达式
const createComparisonExpr = (condition: ConditionItem): Expr => {
const selectors = condition.leftSelector.split("#");
const t = selectors[2] as WorkflowNodeIoValueType;
const left: Expr = {
type: ExprType.Var,
selector: {
id: selectors[0],
name: selectors[1],
type: t,
},
};
const right: Expr = { type: ExprType.Const, value: condition.rightValue, valueType: t };
return {
type: ExprType.Compare,
op: condition.operator,
left,
right,
};
};
// 如果只有一个条件,直接返回比较表达式
if (values.conditions.length === 1) {
return createComparisonExpr(values.conditions[0]);
}
// 多个条件,通过逻辑运算符连接
let expr: Expr = createComparisonExpr(values.conditions[0]);
for (let i = 1; i < values.conditions.length; i++) {
expr = {
type: ExprType.Logical,
op: values.logicalOperator,
left: expr,
right: createComparisonExpr(values.conditions[i]),
};
}
return expr;
};
const [drawerOpen, setDrawerOpen] = useState(false);
const handleDrawerConfirm = async () => {
setFormPending(true);
@ -84,10 +35,9 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP
try {
const newValues = getFormValues();
const expression = formToExpression(newValues);
const newNode = produce(node, (draft) => {
draft.config = {
expression,
...newValues,
};
draft.validated = true;
});
@ -100,7 +50,7 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP
return (
<>
<Popover
classNames={{ root: "shadow-md" }}
classNames={{ root: "mt-20 shadow-md" }}
styles={{ body: { padding: 0 } }}
arrow={false}
content={
@ -116,25 +66,34 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP
>
<Card className="relative z-[1] mt-10 w-[256px] shadow-md" styles={{ body: { padding: 0 } }} hoverable onClick={() => setDrawerOpen(true)}>
<div className="flex h-[48px] flex-col items-center justify-center truncate px-4 py-2">
<div className="relative w-full overflow-hidden" onClick={(e) => e.stopPropagation()}>
<SharedNode.Title
className="focus:bg-background focus:text-foreground overflow-hidden outline-slate-200 focus:rounded-sm"
node={node}
disabled={disabled}
/>
<div className="absolute right-0 top-1/2 -translate-y-1/2" onClick={() => setDrawerOpen(true)}>
{node.config?.expression ? (
<Button color="primary" icon={<FilterFilledIcon />} variant="link" />
) : (
<Button color="default" icon={<FilterOutlinedIcon />} variant="link" />
)}
</div>
</div>
</div>
</Card>
</Popover>
<SharedNode.ConfigDrawer
getConfigNewValues={getFormValues}
node={node}
open={drawerOpen}
pending={formPending}
onConfirm={handleDrawerConfirm}
onOpenChange={(open) => setDrawerOpen(open)}
getFormValues={() => formRef.current!.getFieldsValue()}
>
<ConditionNodeConfigForm nodeId={node.id} ref={formRef} disabled={disabled} initialValues={node.config} />
</SharedNode.ConfigDrawer>
</Popover>
<AddNode node={node} disabled={disabled} />
</>

View File

@ -1,36 +1,16 @@
import { forwardRef, memo, useEffect, useImperativeHandle, useState } from "react";
import { Button, Card, Form, Input, Select, Radio } from "antd";
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
import i18n from "@/i18n";
import {
WorkflowNodeConfigForCondition,
Expr,
WorkflowNodeIOValueSelector,
ComparisonOperator,
LogicalOperator,
isConstExpr,
isVarExpr,
WorkflowNode,
workflowNodeIOOptions,
WorkflowNodeIoValueType,
ExprType,
} from "@/domain/workflow";
import { FormInstance } from "antd";
import { useZustandShallowSelector } from "@/hooks";
import { useWorkflowStore } from "@/stores/workflow";
import { forwardRef, memo, useImperativeHandle, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Form, type FormInstance } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
// 表单内部使用的扁平结构 - 修改后只保留必要字段
export interface ConditionItem {
leftSelector: string;
operator: ComparisonOperator;
rightValue: string;
}
import { type Expr, type WorkflowNodeConfigForCondition } from "@/domain/workflow";
import { useAntdForm } from "@/hooks";
import ConditionNodeConfigFormExpressionEditor, { type ConditionNodeConfigFormExpressionEditorInstance } from "./ConditionNodeConfigFormExpressionEditor";
export type ConditionNodeConfigFormFieldValues = {
conditions: ConditionItem[];
logicalOperator: LogicalOperator;
expression?: Expr | undefined;
};
export type ConditionNodeConfigFormProps = {
@ -38,9 +18,8 @@ export type ConditionNodeConfigFormProps = {
style?: React.CSSProperties;
disabled?: boolean;
initialValues?: Partial<WorkflowNodeConfigForCondition>;
onValuesChange?: (values: WorkflowNodeConfigForCondition) => void;
availableSelectors?: WorkflowNodeIOValueSelector[];
nodeId: string;
onValuesChange?: (values: WorkflowNodeConfigForCondition) => void;
};
export type ConditionNodeConfigFormInstance = {
@ -49,298 +28,49 @@ export type ConditionNodeConfigFormInstance = {
validateFields: FormInstance<ConditionNodeConfigFormFieldValues>["validateFields"];
};
// 初始表单值
const initFormModel = (): ConditionNodeConfigFormFieldValues => {
return {
conditions: [
{
leftSelector: "",
operator: "==",
rightValue: "",
},
],
logicalOperator: LogicalOperator.And,
};
};
// 递归提取表达式中的条件项
const expressionToForm = (expr?: Expr): ConditionNodeConfigFormFieldValues => {
if (!expr) return initFormModel();
const conditions: ConditionItem[] = [];
let logicalOp: LogicalOperator = LogicalOperator.And;
const extractComparisons = (expr: Expr): void => {
if (expr.type === ExprType.Compare) {
// 确保左侧是变量,右侧是常量
if (isVarExpr(expr.left) && isConstExpr(expr.right)) {
conditions.push({
leftSelector: `${expr.left.selector.id}#${expr.left.selector.name}#${expr.left.selector.type}`,
operator: expr.op,
rightValue: String(expr.right.value),
});
}
} else if (expr.type === ExprType.Logical) {
logicalOp = expr.op;
extractComparisons(expr.left);
extractComparisons(expr.right);
}
};
extractComparisons(expr);
return {
conditions: conditions.length > 0 ? conditions : initFormModel().conditions,
logicalOperator: logicalOp,
};
};
// 根据变量类型获取适当的操作符选项
const getOperatorsByType = (type: string): { value: ComparisonOperator; label: string }[] => {
switch (type) {
case "number":
case "string":
return [
{ value: "==", label: i18n.t("workflow_node.condition.form.comparison.equal") },
{ value: "!=", label: i18n.t("workflow_node.condition.form.comparison.not_equal") },
{ value: ">", label: i18n.t("workflow_node.condition.form.comparison.greater_than") },
{ value: ">=", label: i18n.t("workflow_node.condition.form.comparison.greater_than_or_equal") },
{ value: "<", label: i18n.t("workflow_node.condition.form.comparison.less_than") },
{ value: "<=", label: i18n.t("workflow_node.condition.form.comparison.less_than_or_equal") },
];
case "boolean":
return [{ value: "is", label: i18n.t("workflow_node.condition.form.comparison.is") }];
default:
return [];
}
};
// 从选择器字符串中提取变量类型
const getVariableTypeFromSelector = (selector: string): string => {
if (!selector) return "string";
// 假设选择器格式为 "id#name#type"
const parts = selector.split("#");
if (parts.length >= 3) {
return parts[2].toLowerCase() || "string";
}
return "string";
return {};
};
const ConditionNodeConfigForm = forwardRef<ConditionNodeConfigFormInstance, ConditionNodeConfigFormProps>(
({ className, style, disabled, initialValues, onValuesChange, nodeId }, ref) => {
({ className, style, disabled, initialValues, nodeId, onValuesChange }, ref) => {
const { t } = useTranslation();
const prefix = "workflow_node.condition.form";
const { getWorkflowOuptutBeforeId } = useWorkflowStore(useZustandShallowSelector(["updateNode", "getWorkflowOuptutBeforeId"]));
const formSchema = z.object({
expression: z.any().nullish(),
});
const formRule = createSchemaFieldRule(formSchema);
const { form: formInst, formProps } = useAntdForm({
name: "workflowNodeConditionConfigForm",
initialValues: initialValues ?? initFormModel(),
});
const [form] = Form.useForm<ConditionNodeConfigFormFieldValues>();
const [formModel, setFormModel] = useState<ConditionNodeConfigFormFieldValues>(initFormModel());
const editorRef = useRef<ConditionNodeConfigFormExpressionEditorInstance>(null);
const [previousNodes, setPreviousNodes] = useState<WorkflowNode[]>([]);
useEffect(() => {
const previousNodes = getWorkflowOuptutBeforeId(nodeId);
setPreviousNodes(previousNodes);
}, [nodeId]);
// 初始化表单值
useEffect(() => {
if (initialValues?.expression) {
const formValues = expressionToForm(initialValues.expression);
form.setFieldsValue(formValues);
setFormModel(formValues);
}
}, [form, initialValues]);
// 公开表单方法
useImperativeHandle(
ref,
() => ({
getFieldsValue: form.getFieldsValue,
resetFields: form.resetFields,
validateFields: form.validateFields,
}),
[form]
);
// 表单值变更处理
const handleFormChange = (_: undefined, values: ConditionNodeConfigFormFieldValues) => {
setFormModel(values);
if (onValuesChange) {
// 将表单值转换为表达式
const expression = formToExpression(values);
onValuesChange({ expression });
}
const handleFormChange = (_: unknown, values: z.infer<typeof formSchema>) => {
onValuesChange?.(values);
};
return (
<Form form={form} className={className} style={style} layout="vertical" disabled={disabled} initialValues={formModel} onValuesChange={handleFormChange}>
<Form.List name="conditions">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Card
key={key}
size="small"
className="mb-3"
extra={fields.length > 1 ? <Button icon={<DeleteOutlined />} danger type="text" onClick={() => remove(name)} /> : null}
>
<div className="flex items-center gap-2">
{/* 左侧变量选择器 */}
<Form.Item
{...restField}
name={[name, "leftSelector"]}
className="mb-0 flex-1"
rules={[{ required: true, message: t(`${prefix}.variable.errmsg`) }]}
>
<Select
placeholder={t(`${prefix}.variable.placeholder`)}
options={previousNodes.map((item) => {
return workflowNodeIOOptions(item);
})}
/>
</Form.Item>
{/* 操作符 - 动态根据变量类型改变选项 */}
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => {
return prevValues.conditions?.[name]?.leftSelector !== currentValues.conditions?.[name]?.leftSelector;
}}
>
{({ getFieldValue }) => {
const leftSelector = getFieldValue(["conditions", name, "leftSelector"]);
const varType = getVariableTypeFromSelector(leftSelector);
const operators = getOperatorsByType(varType);
useImperativeHandle(ref, () => {
return {
getFieldsValue: formInst.getFieldsValue,
resetFields: formInst.resetFields,
validateFields: (nameList, config) => {
const t1 = formInst.validateFields(nameList, config);
const t2 = editorRef.current!.validate();
return Promise.all([t1, t2]).then(() => t1);
},
} as ConditionNodeConfigFormInstance;
});
return (
<Form.Item
{...restField}
name={[name, "operator"]}
className="mb-0 w-32"
rules={[{ required: true, message: t(`${prefix}.operator.errmsg`) }]}
>
<Select options={operators} />
<Form className={className} style={style} {...formProps} disabled={disabled} layout="vertical" scrollToFirstError onValuesChange={handleFormChange}>
<Form.Item name="expression" label={t("workflow_node.condition.form.expression.label")} rules={[formRule]}>
<ConditionNodeConfigFormExpressionEditor ref={editorRef} nodeId={nodeId} />
</Form.Item>
);
}}
</Form.Item>
{/* 右侧输入控件 - 根据变量类型使用不同的控件 */}
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => {
return prevValues.conditions?.[name]?.leftSelector !== currentValues.conditions?.[name]?.leftSelector;
}}
>
{({ getFieldValue }) => {
const leftSelector = getFieldValue(["conditions", name, "leftSelector"]);
const varType = getVariableTypeFromSelector(leftSelector);
return (
<Form.Item
{...restField}
name={[name, "rightValue"]}
className="mb-0 flex-1"
rules={[{ required: true, message: t(`${prefix}.value.errmsg`) }]}
>
{varType === "boolean" ? (
<Select placeholder={t(`${prefix}.value.boolean.placeholder`)}>
<Select.Option value="true">{t(`${prefix}.value.boolean.true`)}</Select.Option>
<Select.Option value="false">{t(`${prefix}.value.boolean.false`)}</Select.Option>
</Select>
) : varType === "number" ? (
<Input type="number" placeholder={t(`${prefix}.value.number.placeholder`)} />
) : (
<Input placeholder={t(`${prefix}.value.string.placeholder`)} />
)}
</Form.Item>
);
}}
</Form.Item>
</div>
</Card>
))}
{/* 添加条件按钮 */}
<Form.Item>
<Button
type="dashed"
onClick={() =>
add({
leftSelector: "",
operator: "==",
rightValue: "",
})
}
block
icon={<PlusOutlined />}
>
{t(`${prefix}.add_condition.button`)}
</Button>
</Form.Item>
</>
)}
</Form.List>
{formModel.conditions && formModel.conditions.length > 1 && (
<Form.Item name="logicalOperator" label={t(`${prefix}.logical_operator.label`)}>
<Radio.Group buttonStyle="solid">
<Radio.Button value="and">{t(`${prefix}.logical_operator.and`)}</Radio.Button>
<Radio.Button value="or">{t(`${prefix}.logical_operator.or`)}</Radio.Button>
</Radio.Group>
</Form.Item>
)}
</Form>
);
}
);
// 表单值转换为表达式结构 (需要添加)
const formToExpression = (values: ConditionNodeConfigFormFieldValues): Expr => {
const createComparisonExpr = (condition: ConditionItem): Expr => {
const [id, name, typeStr] = condition.leftSelector.split("#");
const type = typeStr as WorkflowNodeIoValueType;
const left: Expr = {
type: ExprType.Var,
selector: { id, name, type },
};
const right: Expr = {
type: ExprType.Const,
value: condition.rightValue,
valueType: type,
};
return {
type: ExprType.Compare,
op: condition.operator,
left,
right,
};
};
// 如果只有一个条件,直接返回比较表达式
if (values.conditions.length === 1) {
return createComparisonExpr(values.conditions[0]);
}
// 多个条件,通过逻辑运算符连接
let expr: Expr = createComparisonExpr(values.conditions[0]);
for (let i = 1; i < values.conditions.length; i++) {
expr = {
type: ExprType.Logical,
op: values.logicalOperator,
left: expr,
right: createComparisonExpr(values.conditions[i]),
};
}
return expr;
};
export default memo(ConditionNodeConfigForm);

View File

@ -0,0 +1,400 @@
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { CloseOutlined as CloseOutlinedIcon, PlusOutlined } from "@ant-design/icons";
import { useControllableValue } from "ahooks";
import { Button, Form, Input, Radio, Select, theme } from "antd";
import Show from "@/components/Show";
import type { Expr, ExprComparisonOperator, ExprLogicalOperator, ExprValue, ExprValueSelector, ExprValueType } from "@/domain/workflow";
import { ExprType } from "@/domain/workflow";
import { useAntdFormName, useZustandShallowSelector } from "@/hooks";
import { useWorkflowStore } from "@/stores/workflow";
export type ConditionNodeConfigFormExpressionEditorProps = {
className?: string;
style?: React.CSSProperties;
defaultValue?: Expr;
disabled?: boolean;
nodeId: string;
value?: Expr;
onChange?: (value: Expr) => void;
};
export type ConditionNodeConfigFormExpressionEditorInstance = {
validate: () => Promise<void>;
};
// 表单内部使用的扁平结构
type ConditionItem = {
// 选择器,格式为 "${nodeId}#${outputName}#${valueType}"
// 将 [ExprValueSelector] 转为字符串形式,以便于结构化存储。
leftSelector?: string;
// 比较运算符。
operator?: ExprComparisonOperator;
// 值。
// 将 [ExprValue] 转为字符串形式,以便于结构化存储。
rightValue?: string;
};
type ConditionFormValues = {
conditions: ConditionItem[];
logicalOperator: ExprLogicalOperator;
};
const initFormModel = (): ConditionFormValues => {
return {
conditions: [{}],
logicalOperator: "and",
};
};
const exprToFormValues = (expr?: Expr): ConditionFormValues => {
if (!expr) return initFormModel();
const conditions: ConditionItem[] = [];
let logicalOp: ExprLogicalOperator = "and";
const extractExpr = (expr: Expr): void => {
if (expr.type === ExprType.Comparison) {
if (expr.left.type == ExprType.Variant && expr.right.type == ExprType.Constant) {
conditions.push({
leftSelector: expr.left.selector?.id != null ? `${expr.left.selector.id}#${expr.left.selector.name}#${expr.left.selector.type}` : undefined,
operator: expr.operator != null ? expr.operator : undefined,
rightValue: expr.right?.value != null ? String(expr.right.value) : undefined,
});
} else {
console.warn("[certimate] invalid comparison expression: left must be a variant and right must be a constant", expr);
}
} else if (expr.type === ExprType.Logical) {
logicalOp = expr.operator || "and";
extractExpr(expr.left);
extractExpr(expr.right);
}
};
extractExpr(expr);
return {
conditions: conditions,
logicalOperator: logicalOp,
};
};
const formValuesToExpr = (values: ConditionFormValues): Expr | undefined => {
const wrapExpr = (condition: ConditionItem): Expr => {
const [id, name, type] = (condition.leftSelector?.split("#") ?? ["", "", ""]) as [string, string, ExprValueType];
const valid = !!id && !!name && !!type;
const left: Expr = {
type: ExprType.Variant,
selector: valid
? {
id: id,
name: name,
type: type,
}
: ({} as ExprValueSelector),
};
const right: Expr = {
type: ExprType.Constant,
value: condition.rightValue!,
valueType: type,
};
return {
type: ExprType.Comparison,
operator: condition.operator!,
left,
right,
};
};
if (values.conditions.length === 0) {
return undefined;
}
// 只有一个条件时,直接返回比较表达式
if (values.conditions.length === 1) {
const { leftSelector, operator, rightValue } = values.conditions[0];
if (!leftSelector || !operator || !rightValue) {
return undefined;
}
return wrapExpr(values.conditions[0]);
}
// 多个条件时,通过逻辑运算符连接
let expr: Expr = wrapExpr(values.conditions[0]);
for (let i = 1; i < values.conditions.length; i++) {
expr = {
type: ExprType.Logical,
operator: values.logicalOperator,
left: expr,
right: wrapExpr(values.conditions[i]),
};
}
return expr;
};
const ConditionNodeConfigFormExpressionEditor = forwardRef<ConditionNodeConfigFormExpressionEditorInstance, ConditionNodeConfigFormExpressionEditorProps>(
({ className, style, disabled, nodeId, ...props }, ref) => {
const { t } = useTranslation();
const { token: themeToken } = theme.useToken();
const { getWorkflowOuptutBeforeId } = useWorkflowStore(useZustandShallowSelector(["updateNode", "getWorkflowOuptutBeforeId"]));
const [value, setValue] = useControllableValue<Expr | undefined>(props, {
valuePropName: "value",
defaultValuePropName: "defaultValue",
trigger: "onChange",
});
const [formInst] = Form.useForm<ConditionFormValues>();
const formName = useAntdFormName({ form: formInst, name: "workflowNodeConditionConfigFormExpressionEditorForm" });
const [formModel, setFormModel] = useState<ConditionFormValues>(initFormModel());
useEffect(() => {
if (value) {
const formValues = exprToFormValues(value);
formInst.setFieldsValue(formValues);
setFormModel(formValues);
} else {
formInst.resetFields();
setFormModel(initFormModel());
}
}, [value]);
const ciSelectorCandidates = useMemo(() => {
const previousNodes = getWorkflowOuptutBeforeId(nodeId);
return previousNodes
.map((node) => {
const group = {
label: node.name,
options: Array<{ label: string; value: string }>(),
};
for (const output of node.outputs ?? []) {
switch (output.type) {
case "certificate":
group.options.push({
label: `${output.label} - ${t("workflow.variables.selector.validity.label")}`,
value: `${node.id}#${output.name}.validity#boolean`,
});
group.options.push({
label: `${output.label} - ${t("workflow.variables.selector.days_left.label")}`,
value: `${node.id}#${output.name}.daysLeft#number`,
});
break;
default:
group.options.push({
label: `${output.label}`,
value: `${node.id}#${output.name}#${output.type}`,
});
console.warn("[certimate] invalid workflow output type in condition expressions", output);
break;
}
}
return group;
})
.filter((item) => item.options.length > 0);
}, [nodeId]);
const getValueTypeBySelector = (selector: string): ExprValueType | undefined => {
if (!selector) return;
const parts = selector.split("#");
if (parts.length >= 3) {
return parts[2].toLowerCase() as ExprValueType;
}
};
const getOperatorsBySelector = (selector: string): { value: ExprComparisonOperator; label: string }[] => {
const valueType = getValueTypeBySelector(selector);
return getOperatorsByValueType(valueType!);
};
const getOperatorsByValueType = (valueType: ExprValue): { value: ExprComparisonOperator; label: string }[] => {
switch (valueType) {
case "number":
return [
{ value: "eq", label: t("workflow_node.condition.form.expression.operator.option.eq.label") },
{ value: "neq", label: t("workflow_node.condition.form.expression.operator.option.neq.label") },
{ value: "gt", label: t("workflow_node.condition.form.expression.operator.option.gt.label") },
{ value: "gte", label: t("workflow_node.condition.form.expression.operator.option.gte.label") },
{ value: "lt", label: t("workflow_node.condition.form.expression.operator.option.lt.label") },
{ value: "lte", label: t("workflow_node.condition.form.expression.operator.option.lte.label") },
];
case "string":
return [
{ value: "eq", label: t("workflow_node.condition.form.expression.operator.option.eq.label") },
{ value: "neq", label: t("workflow_node.condition.form.expression.operator.option.neq.label") },
];
case "boolean":
return [
{ value: "eq", label: t("workflow_node.condition.form.expression.operator.option.eq.alias_is_label") },
{ value: "neq", label: t("workflow_node.condition.form.expression.operator.option.neq.alias_not_label") },
];
default:
return [];
}
};
const handleFormChange = (_: undefined, values: ConditionFormValues) => {
setValue(formValuesToExpr(values));
};
useImperativeHandle(ref, () => {
return {
validate: async () => {
await formInst.validateFields();
},
} as ConditionNodeConfigFormExpressionEditorInstance;
});
return (
<Form
className={className}
style={style}
form={formInst}
disabled={disabled}
initialValues={formModel}
layout="vertical"
name={formName}
onValuesChange={handleFormChange}
>
<Show when={formModel.conditions?.length > 1}>
<Form.Item
className="mb-2"
name="logicalOperator"
rules={[{ required: true, message: t("workflow_node.condition.form.expression.logical_operator.errmsg") }]}
>
<Radio.Group block>
<Radio.Button value="and">{t("workflow_node.condition.form.expression.logical_operator.option.and.label")}</Radio.Button>
<Radio.Button value="or">{t("workflow_node.condition.form.expression.logical_operator.option.or.label")}</Radio.Button>
</Radio.Group>
</Form.Item>
</Show>
<Form.List name="conditions">
{(fields, { add, remove }) => (
<div className="flex flex-col gap-2">
{fields.map(({ key, name: index, ...rest }) => (
<div key={key} className="flex gap-2">
{/* 左:变量选择器 */}
<Form.Item
className="mb-0 flex-1"
name={[index, "leftSelector"]}
rules={[{ required: true, message: t("workflow_node.condition.form.expression.variable.errmsg") }]}
{...rest}
>
<Select
labelRender={({ label, value }) => {
if (value != null) {
const group = ciSelectorCandidates.find((group) => group.options.some((option) => option.value === value));
return `${group?.label} - ${label}`;
}
return (
<span style={{ color: themeToken.colorTextPlaceholder }}>{t("workflow_node.condition.form.expression.variable.placeholder")}</span>
);
}}
options={ciSelectorCandidates}
placeholder={t("workflow_node.condition.form.expression.variable.placeholder")}
/>
</Form.Item>
{/* 中:运算符选择器,根据变量类型决定选项 */}
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => {
return prevValues.conditions?.[index]?.leftSelector !== currentValues.conditions?.[index]?.leftSelector;
}}
>
{({ getFieldValue }) => {
const leftSelector = getFieldValue(["conditions", index, "leftSelector"]);
const operators = getOperatorsBySelector(leftSelector);
return (
<Form.Item
className="mb-0 w-36"
name={[index, "operator"]}
rules={[{ required: true, message: t("workflow_node.condition.form.expression.operator.errmsg") }]}
{...rest}
>
<Select
open={operators.length === 0 ? false : undefined}
options={operators}
placeholder={t("workflow_node.condition.form.expression.operator.placeholder")}
/>
</Form.Item>
);
}}
</Form.Item>
{/* 右:输入控件,根据变量类型决定组件 */}
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => {
return prevValues.conditions?.[index]?.leftSelector !== currentValues.conditions?.[index]?.leftSelector;
}}
>
{({ getFieldValue }) => {
const leftSelector = getFieldValue(["conditions", index, "leftSelector"]);
const valueType = getValueTypeBySelector(leftSelector);
return (
<Form.Item
className="mb-0 w-36"
name={[index, "rightValue"]}
rules={[{ required: true, message: t("workflow_node.condition.form.expression.value.errmsg") }]}
{...rest}
>
{valueType === "string" ? (
<Input placeholder={t("workflow_node.condition.form.expression.value.placeholder")} />
) : valueType === "number" ? (
<Input type="number" placeholder={t("workflow_node.condition.form.expression.value.placeholder")} />
) : valueType === "boolean" ? (
<Select placeholder={t("workflow_node.condition.form.expression.value.placeholder")}>
<Select.Option value="true">{t("workflow_node.condition.form.expression.value.option.true.label")}</Select.Option>
<Select.Option value="false">{t("workflow_node.condition.form.expression.value.option.false.label")}</Select.Option>
</Select>
) : (
<Input readOnly placeholder={t("workflow_node.condition.form.expression.value.placeholder")} />
)}
</Form.Item>
);
}}
</Form.Item>
<Button
className="my-1"
color="default"
disabled={disabled}
icon={<CloseOutlinedIcon />}
size="small"
type="text"
onClick={() => remove(index)}
/>
</div>
))}
<Form.Item>
<Button type="dashed" block icon={<PlusOutlined />} onClick={() => add({})}>
{t("workflow_node.condition.form.expression.add_condition.button")}
</Button>
</Form.Item>
</div>
)}
</Form.List>
</Form>
);
}
);
export default ConditionNodeConfigFormExpressionEditor;

View File

@ -24,10 +24,10 @@ const DeployNode = ({ node, disabled }: DeployNodeProps) => {
const formRef = useRef<DeployNodeConfigFormInstance>(null);
const [formPending, setFormPending] = useState(false);
const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForDeploy;
const [drawerOpen, setDrawerOpen] = useState(false);
const [drawerFooterShow, setDrawerFooterShow] = useState(true);
const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForDeploy;
useEffect(() => {
setDrawerFooterShow(!!(node.config as WorkflowNodeConfigForDeploy)?.provider);
@ -86,8 +86,9 @@ const DeployNode = ({ node, disabled }: DeployNodeProps) => {
</SharedNode.Block>
<SharedNode.ConfigDrawer
node={node}
footer={drawerFooterShow}
getConfigNewValues={getFormValues}
node={node}
open={drawerOpen}
pending={formPending}
onConfirm={handleDrawerConfirm}
@ -95,7 +96,6 @@ const DeployNode = ({ node, disabled }: DeployNodeProps) => {
setDrawerFooterShow(!!(node.config as WorkflowNodeConfigForDeploy)?.provider);
setDrawerOpen(open);
}}
getFormValues={() => formRef.current!.getFieldsValue()}
>
<DeployNodeConfigForm ref={formRef} disabled={disabled} initialValues={node.config} nodeId={node.id} onValuesChange={handleFormValuesChange} />
</SharedNode.ConfigDrawer>

View File

@ -1,7 +1,7 @@
import { forwardRef, memo, useEffect, useImperativeHandle, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { PlusOutlined as PlusOutlinedIcon, QuestionCircleOutlined as QuestionCircleOutlinedIcon } from "@ant-design/icons";
import { Button, Divider, Flex, Form, type FormInstance, Select, Switch, Tooltip, Typography } from "antd";
import { Button, Divider, Flex, Form, type FormInstance, Select, Switch, Tooltip, Typography, theme } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
@ -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 WorkflowNode, type WorkflowNodeConfigForDeploy } from "@/domain/workflow";
import { type WorkflowNodeConfigForDeploy, WorkflowNodeType } from "@/domain/workflow";
import { useAntdForm, useAntdFormName, useZustandShallowSelector } from "@/hooks";
import { useWorkflowStore } from "@/stores/workflow";
@ -125,14 +125,9 @@ const DeployNodeConfigForm = forwardRef<DeployNodeConfigFormInstance, DeployNode
({ className, style, disabled, initialValues, nodeId, onValuesChange }, ref) => {
const { t } = useTranslation();
const { getWorkflowOuptutBeforeId } = useWorkflowStore(useZustandShallowSelector(["updateNode", "getWorkflowOuptutBeforeId"]));
const { token: themeToken } = theme.useToken();
// TODO: 优化此处逻辑
const [previousNodes, setPreviousNodes] = useState<WorkflowNode[]>([]);
useEffect(() => {
const previousNodes = getWorkflowOuptutBeforeId(nodeId, "certificate");
setPreviousNodes(previousNodes);
}, [nodeId]);
const { getWorkflowOuptutBeforeId } = useWorkflowStore(useZustandShallowSelector(["updateNode", "getWorkflowOuptutBeforeId"]));
const formSchema = z.object({
certificate: z
@ -170,6 +165,24 @@ const DeployNodeConfigForm = forwardRef<DeployNodeConfigFormInstance, DeployNode
}
}, [fieldProvider]);
const certificateCandidates = useMemo(() => {
const previousNodes = getWorkflowOuptutBeforeId(nodeId, "certificate");
return previousNodes
.filter((node) => node.type === WorkflowNodeType.Apply || node.type === WorkflowNodeType.Upload)
.map((item) => {
return {
label: item.name,
options: (item.outputs ?? [])?.map((output) => {
return {
label: output.label,
value: `${item.id}#${output.name}`,
};
}),
};
})
.filter((group) => group.options.length > 0);
}, [nodeId]);
const [nestedFormInst] = Form.useForm();
const nestedFormName = useAntdFormName({ form: nestedFormInst, name: "workflowNodeDeployConfigFormProviderConfigForm" });
const nestedFormEl = useMemo(() => {
@ -487,17 +500,15 @@ const DeployNodeConfigForm = forwardRef<DeployNodeConfigFormInstance, DeployNode
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.deploy.form.certificate.tooltip") }}></span>}
>
<Select
options={previousNodes.map((item) => {
return {
label: item.name,
options: item.outputs?.map((output) => {
return {
label: `${item.name} - ${output.label}`,
value: `${item.id}#${output.name}`,
};
}),
};
})}
labelRender={({ label, value }) => {
if (value != null) {
const group = certificateCandidates.find((group) => group.options.some((option) => option.value === value));
return `${group?.label} - ${label}`;
}
return <span style={{ color: themeToken.colorTextPlaceholder }}>{t("workflow_node.deploy.form.certificate.placeholder")}</span>;
}}
options={certificateCandidates}
placeholder={t("workflow_node.deploy.form.certificate.placeholder")}
/>
</Form.Item>

View File

@ -19,7 +19,7 @@ export type DeployNodeConfigFormAliyunCASDeployConfigProps = {
onValuesChange?: (values: DeployNodeConfigFormAliyunCASDeployConfigFieldValues) => void;
};
const MULTIPLE_INPUT_DELIMITER = ";";
const MULTIPLE_INPUT_SEPARATOR = ";";
const initFormModel = (): DeployNodeConfigFormAliyunCASDeployConfigFieldValues => {
return {};
@ -42,7 +42,7 @@ const DeployNodeConfigFormAliyunCASDeployConfig = ({
resourceIds: z.string({ message: t("workflow_node.deploy.form.aliyun_cas_deploy_resource_ids.placeholder") }).refine((v) => {
if (!v) return false;
return String(v)
.split(MULTIPLE_INPUT_DELIMITER)
.split(MULTIPLE_INPUT_SEPARATOR)
.every((e) => /^[1-9]\d*$/.test(e));
}, t("workflow_node.deploy.form.aliyun_cas_deploy_resource_ids.errmsg.invalid")),
contactIds: z
@ -51,7 +51,7 @@ const DeployNodeConfigFormAliyunCASDeployConfig = ({
.refine((v) => {
if (!v) return true;
return String(v)
.split(MULTIPLE_INPUT_DELIMITER)
.split(MULTIPLE_INPUT_SEPARATOR)
.every((e) => /^[1-9]\d*$/.test(e));
}, t("workflow_node.deploy.form.aliyun_cas_deploy_contact_ids.errmsg.invalid")),
});

View File

@ -23,7 +23,7 @@ export type DeployNodeConfigFormBaotaPanelSiteConfigProps = {
const SITE_TYPE_PHP = "php";
const SITE_TYPE_OTHER = "other";
const MULTIPLE_INPUT_DELIMITER = ";";
const MULTIPLE_INPUT_SEPARATOR = ";";
const initFormModel = (): DeployNodeConfigFormBaotaPanelSiteConfigFieldValues => {
return {
@ -60,7 +60,7 @@ const DeployNodeConfigFormBaotaPanelSiteConfig = ({
if (fieldSiteType !== SITE_TYPE_OTHER) return true;
if (!v) return false;
return String(v)
.split(MULTIPLE_INPUT_DELIMITER)
.split(MULTIPLE_INPUT_SEPARATOR)
.every((e) => !!e.trim());
}, t("workflow_node.deploy.form.baotapanel_site_names.placeholder")),
});

View File

@ -19,7 +19,7 @@ export type DeployNodeConfigFormTencentCloudSSLDeployConfigProps = {
onValuesChange?: (values: DeployNodeConfigFormTencentCloudSSLDeployConfigFieldValues) => void;
};
const MULTIPLE_INPUT_DELIMITER = ";";
const MULTIPLE_INPUT_SEPARATOR = ";";
const initFormModel = (): DeployNodeConfigFormTencentCloudSSLDeployConfigFieldValues => {
return {};
@ -46,7 +46,7 @@ const DeployNodeConfigFormTencentCloudSSLDeployConfig = ({
resourceIds: z.string({ message: t("workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.placeholder") }).refine((v) => {
if (!v) return false;
return String(v)
.split(MULTIPLE_INPUT_DELIMITER)
.split(MULTIPLE_INPUT_SEPARATOR)
.every((e) => /^[A-Za-z0-9*._-|]+$/.test(e));
}, t("workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.errmsg.invalid")),
});

View File

@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { Form, type FormInstance, Input, Select } from "antd";
import { Alert, Form, type FormInstance, Input, Select } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
@ -56,6 +56,10 @@ const DeployNodeConfigFormUniCloudWebHostConfig = ({
name={formName}
onValuesChange={handleFormChange}
>
<Form.Item>
<Alert type="info" message={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.deploy.form.unicloud_webhost.guide") }}></span>} />
</Form.Item>
<Form.Item name="spaceProvider" label={t("workflow_node.deploy.form.unicloud_webhost_space_provider.label")} rules={[formRule]}>
<Select
options={["aliyun", "tencent"].map((s) => ({

View File

@ -18,7 +18,7 @@ export type DeployNodeConfigFormWangsuCDNConfigProps = {
onValuesChange?: (values: DeployNodeConfigFormWangsuCDNConfigFieldValues) => void;
};
const MULTIPLE_INPUT_DELIMITER = ";";
const MULTIPLE_INPUT_SEPARATOR = ";";
const initFormModel = (): DeployNodeConfigFormWangsuCDNConfigFieldValues => {
return {
@ -42,7 +42,7 @@ const DeployNodeConfigFormWangsuCDNConfig = ({
.refine((v) => {
if (!v) return false;
return String(v)
.split(MULTIPLE_INPUT_DELIMITER)
.split(MULTIPLE_INPUT_SEPARATOR)
.every((e) => validDomainName(e));
}, t("workflow_node.deploy.form.wangsu_cdn_domains.placeholder")),
});

View File

@ -23,9 +23,9 @@ const MonitorNode = ({ node, disabled }: MonitorNodeProps) => {
const formRef = useRef<MonitorNodeConfigFormInstance>(null);
const [formPending, setFormPending] = useState(false);
const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForMonitor;
const [drawerOpen, setDrawerOpen] = useState(false);
const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForMonitor;
const wrappedEl = useMemo(() => {
if (node.type !== WorkflowNodeType.Monitor) {
@ -74,12 +74,12 @@ const MonitorNode = ({ node, disabled }: MonitorNodeProps) => {
</SharedNode.Block>
<SharedNode.ConfigDrawer
getConfigNewValues={getFormValues}
node={node}
open={drawerOpen}
pending={formPending}
onConfirm={handleDrawerConfirm}
onOpenChange={(open) => setDrawerOpen(open)}
getFormValues={() => formRef.current!.getFieldsValue()}
>
<MonitorNodeConfigForm ref={formRef} disabled={disabled} initialValues={node.config} />
</SharedNode.ConfigDrawer>

View File

@ -25,9 +25,9 @@ const NotifyNode = ({ node, disabled }: NotifyNodeProps) => {
const formRef = useRef<NotifyNodeConfigFormInstance>(null);
const [formPending, setFormPending] = useState(false);
const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForNotify;
const [drawerOpen, setDrawerOpen] = useState(false);
const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForNotify;
const wrappedEl = useMemo(() => {
if (node.type !== WorkflowNodeType.Notify) {
@ -82,12 +82,12 @@ const NotifyNode = ({ node, disabled }: NotifyNodeProps) => {
</SharedNode.Block>
<SharedNode.ConfigDrawer
getConfigNewValues={getFormValues}
node={node}
open={drawerOpen}
pending={formPending}
onConfirm={handleDrawerConfirm}
onOpenChange={(open) => setDrawerOpen(open)}
getFormValues={() => formRef.current!.getFieldsValue()}
>
<NotifyNodeConfigForm ref={formRef} disabled={disabled} initialValues={node.config} />
</SharedNode.ConfigDrawer>

View File

@ -23,9 +23,9 @@ const StartNode = ({ node, disabled }: StartNodeProps) => {
const formRef = useRef<StartNodeConfigFormInstance>(null);
const [formPending, setFormPending] = useState(false);
const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForStart;
const [drawerOpen, setDrawerOpen] = useState(false);
const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForStart;
const wrappedEl = useMemo(() => {
if (node.type !== WorkflowNodeType.Start) {
@ -83,12 +83,12 @@ const StartNode = ({ node, disabled }: StartNodeProps) => {
</SharedNode.Block>
<SharedNode.ConfigDrawer
getConfigNewValues={getFormValues}
node={node}
open={drawerOpen}
pending={formPending}
onConfirm={handleDrawerConfirm}
onOpenChange={(open) => setDrawerOpen(open)}
getFormValues={() => formRef.current!.getFieldsValue()}
>
<StartNodeConfigForm ref={formRef} disabled={disabled} initialValues={node.config} />
</SharedNode.ConfigDrawer>

View File

@ -23,9 +23,9 @@ const UploadNode = ({ node, disabled }: UploadNodeProps) => {
const formRef = useRef<UploadNodeConfigFormInstance>(null);
const [formPending, setFormPending] = useState(false);
const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForUpload;
const [drawerOpen, setDrawerOpen] = useState(false);
const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForUpload;
const wrappedEl = useMemo(() => {
if (node.type !== WorkflowNodeType.Upload) {
@ -74,12 +74,12 @@ const UploadNode = ({ node, disabled }: UploadNodeProps) => {
</SharedNode.Block>
<SharedNode.ConfigDrawer
getConfigNewValues={getFormValues}
node={node}
open={drawerOpen}
pending={formPending}
onConfirm={handleDrawerConfirm}
onOpenChange={(open) => setDrawerOpen(open)}
getFormValues={() => formRef.current!.getFieldsValue()}
>
<UploadNodeConfigForm ref={formRef} disabled={disabled} initialValues={node.config} />
</SharedNode.ConfigDrawer>

View File

@ -33,7 +33,7 @@ const SharedNodeTitle = ({ className, style, node, disabled }: SharedNodeTitlePr
const handleBlur = (e: React.FocusEvent<HTMLDivElement>) => {
const oldName = node.name;
const newName = e.target.innerText.trim().substring(0, 64) || oldName;
const newName = e.target.innerText.replaceAll("\r", "").replaceAll("\n", "").trim().substring(0, 64) || oldName;
if (oldName === newName) {
return;
}
@ -45,9 +45,16 @@ const SharedNodeTitle = ({ className, style, node, disabled }: SharedNodeTitlePr
);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.code === "Enter") {
e.preventDefault();
e.currentTarget.blur();
}
};
return (
<div className="w-full cursor-text overflow-hidden text-center">
<div className={className} style={style} contentEditable={!disabled} suppressContentEditableWarning onBlur={handleBlur}>
<div className={className} style={style} contentEditable={!disabled} suppressContentEditableWarning onBlur={handleBlur} onKeyDown={handleKeyDown}>
{node.name}
</div>
</div>
@ -91,7 +98,7 @@ const SharedNodeMenu = ({ menus, trigger, node, disabled, branchId, branchIndex,
const handleRenameConfirm = async () => {
const oldName = node.name;
const newName = nameRef.current?.trim()?.substring(0, 64) || oldName;
const newName = nameRef.current?.replaceAll("\r", "")?.replaceAll("\n", "").trim()?.substring(0, 64) || oldName;
if (oldName === newName) {
return;
}
@ -195,7 +202,7 @@ const SharedNodeMenu = ({ menus, trigger, node, disabled, branchId, branchIndex,
};
// #endregion
// #region Wrapper
// #region Block
type SharedNodeBlockProps = SharedNodeProps & {
children: React.ReactNode;
onClick?: (e: React.MouseEvent) => void;
@ -245,7 +252,7 @@ type SharedNodeEditDrawerProps = SharedNodeProps & {
pending?: boolean;
onOpenChange?: (open: boolean) => void;
onConfirm: () => void | Promise<unknown>;
getFormValues: () => NonNullable<unknown>;
getConfigNewValues: () => NonNullable<unknown>; // 用于获取节点配置的新值,以便在抽屉关闭前进行对比,决定是否提示保存
};
const SharedNodeConfigDrawer = ({
@ -256,7 +263,7 @@ const SharedNodeConfigDrawer = ({
loading,
pending,
onConfirm,
getFormValues,
getConfigNewValues,
...props
}: SharedNodeEditDrawerProps) => {
const { t } = useTranslation();
@ -284,7 +291,7 @@ const SharedNodeConfigDrawer = ({
if (pending) return;
const oldValues = JSON.parse(JSON.stringify(node.config ?? {}));
const newValues = JSON.parse(JSON.stringify(getFormValues()));
const newValues = JSON.parse(JSON.stringify(getConfigNewValues()));
const changed = !isEqual(oldValues, {}) && !isEqual(oldValues, newValues);
const { promise, resolve, reject } = Promise.withResolvers();

View File

@ -69,7 +69,7 @@ const workflowNodeTypeDefaultInputs: Map<WorkflowNodeType, WorkflowNodeIO[]> = n
name: "certificate",
type: "certificate",
required: true,
label: i18n.t("workflow.variables.certificate.label"),
label: i18n.t("workflow.variables.type.certificate.label"),
},
],
],
@ -84,7 +84,7 @@ const workflowNodeTypeDefaultOutputs: Map<WorkflowNodeType, WorkflowNodeIO[]> =
name: "certificate",
type: "certificate",
required: true,
label: i18n.t("workflow.variables.certificate.label"),
label: i18n.t("workflow.variables.type.certificate.label"),
},
],
],
@ -95,7 +95,7 @@ const workflowNodeTypeDefaultOutputs: Map<WorkflowNodeType, WorkflowNodeIO[]> =
name: "certificate",
type: "certificate",
required: true,
label: i18n.t("workflow.variables.certificate.label"),
label: i18n.t("workflow.variables.type.certificate.label"),
},
],
],
@ -106,7 +106,7 @@ const workflowNodeTypeDefaultOutputs: Map<WorkflowNodeType, WorkflowNodeIO[]> =
name: "certificate",
type: "certificate",
required: true,
label: i18n.t("workflow.variables.certificate.label"),
label: i18n.t("workflow.variables.type.certificate.label"),
},
],
],
@ -188,7 +188,7 @@ export type WorkflowNodeConfigForNotify = {
};
export type WorkflowNodeConfigForCondition = {
expression: Expr;
expression?: Expr;
};
export type WorkflowNodeConfigForBranch = never;
@ -204,96 +204,35 @@ export type WorkflowNodeIO = {
valueSelector?: WorkflowNodeIOValueSelector;
};
export const VALUE_TYPES = Object.freeze({
STRING: "string",
NUMBER: "number",
BOOLEAN: "boolean",
} as const);
export type WorkflowNodeIoValueType = (typeof VALUE_TYPES)[keyof typeof VALUE_TYPES];
export type WorkflowNodeIOValueSelector = {
id: string;
name: string;
type: WorkflowNodeIoValueType;
};
type WorkflowNodeIOOptions = {
label: string;
value: string;
};
export const workflowNodeIOOptions = (node: WorkflowNode) => {
const rs = {
label: node.name,
options: Array<WorkflowNodeIOOptions>(),
};
if (node.outputs) {
for (const output of node.outputs) {
switch (output.type) {
case "certificate":
rs.options.push({
label: `${node.name} - ${output.label} - ${i18n.t("workflow.variables.is_validated.label")}`,
value: `${node.id}#${output.name}.validated#boolean`,
});
rs.options.push({
label: `${node.name} - ${output.label} - ${i18n.t("workflow.variables.days_left.label")}`,
value: `${node.id}#${output.name}.daysLeft#number`,
});
break;
default:
rs.options.push({
label: `${node.name} - ${output.label}`,
value: `${node.id}#${output.name}#${output.type}`,
});
break;
}
}
}
return rs;
};
export type WorkflowNodeIOValueSelector = ExprValueSelector;
// #endregion
// #region Condition expression
export type Value = string | number | boolean;
export type ComparisonOperator = ">" | "<" | ">=" | "<=" | "==" | "!=" | "is";
export enum LogicalOperator {
And = "and",
Or = "or",
Not = "not",
}
// #region Expression
export enum ExprType {
Const = "const",
Var = "var",
Compare = "compare",
Constant = "const",
Variant = "var",
Comparison = "comparison",
Logical = "logical",
Not = "not",
}
export type ConstExpr = { type: ExprType.Const; value: string; valueType: WorkflowNodeIoValueType };
export type VarExpr = { type: ExprType.Var; selector: WorkflowNodeIOValueSelector };
export type CompareExpr = { type: ExprType.Compare; op: ComparisonOperator; left: Expr; right: Expr };
export type LogicalExpr = { type: ExprType.Logical; op: LogicalOperator; left: Expr; right: Expr };
export type ExprValue = string | number | boolean;
export type ExprValueType = "string" | "number" | "boolean";
export type ExprValueSelector = {
id: string;
name: string;
type: ExprValueType;
};
export type ExprComparisonOperator = "gt" | "gte" | "lt" | "lte" | "eq" | "neq";
export type ExprLogicalOperator = "and" | "or" | "not";
export type ConstantExpr = { type: ExprType.Constant; value: string; valueType: ExprValueType };
export type VariantExpr = { type: ExprType.Variant; selector: ExprValueSelector };
export type ComparisonExpr = { type: ExprType.Comparison; operator: ExprComparisonOperator; left: Expr; right: Expr };
export type LogicalExpr = { type: ExprType.Logical; operator: ExprLogicalOperator; left: Expr; right: Expr };
export type NotExpr = { type: ExprType.Not; expr: Expr };
export type Expr = ConstExpr | VarExpr | CompareExpr | LogicalExpr | NotExpr;
export const isConstExpr = (expr: Expr): expr is ConstExpr => {
return expr.type === ExprType.Const;
};
export const isVarExpr = (expr: Expr): expr is VarExpr => {
return expr.type === ExprType.Var;
};
export type Expr = ConstantExpr | VariantExpr | ComparisonExpr | LogicalExpr | NotExpr;
// #endregion
const isBranchLike = (node: WorkflowNode) => {
@ -352,8 +291,8 @@ export const newNode = (nodeType: WorkflowNodeType, options: NewNodeOptions = {}
switch (nodeType) {
case WorkflowNodeType.Apply:
case WorkflowNodeType.Upload:
case WorkflowNodeType.Deploy:
case WorkflowNodeType.Monitor:
case WorkflowNodeType.Deploy:
{
node.inputs = workflowNodeTypeDefaultInputs.get(nodeType);
node.outputs = workflowNodeTypeDefaultOutputs.get(nodeType);
@ -545,20 +484,24 @@ export const removeBranch = (node: WorkflowNode, branchNodeId: string, branchInd
});
};
const typeEqual = (a: WorkflowNodeIO, t: string) => {
if (t === "all") {
return true;
}
if (a.type === t) {
return true;
}
return false;
};
export const getOutputBeforeNodeId = (root: WorkflowNode, nodeId: string, type: string = "all"): WorkflowNode[] => {
export const getOutputBeforeNodeId = (root: WorkflowNode, nodeId: string, typeFilter?: string | string[]): WorkflowNode[] => {
// 某个分支的节点,不应该能获取到相邻分支上节点的输出
const outputs: WorkflowNode[] = [];
const filter = (io: WorkflowNodeIO) => {
if (typeFilter == null) {
return true;
}
if (Array.isArray(typeFilter) && typeFilter.includes(io.type)) {
return true;
} else if (io.type === typeFilter) {
return true;
}
return false;
};
const traverse = (current: WorkflowNode, output: WorkflowNode[]) => {
if (!current) {
return false;
@ -567,10 +510,10 @@ export const getOutputBeforeNodeId = (root: WorkflowNode, nodeId: string, type:
return true;
}
if (current.type !== WorkflowNodeType.Branch && current.outputs && current.outputs.some((io) => typeEqual(io, type))) {
if (current.type !== WorkflowNodeType.Branch && current.outputs && current.outputs.some((io) => filter(io))) {
output.push({
...current,
outputs: current.outputs.filter((io) => typeEqual(io, type)),
outputs: current.outputs.filter((io) => filter(io)),
});
}

View File

@ -0,0 +1 @@


View File

@ -8,6 +8,7 @@ import nlsSettings from "./nls.settings.json";
import nlsWorkflow from "./nls.workflow.json";
import nlsWorkflowNodes from "./nls.workflow.nodes.json";
import nlsWorkflowRuns from "./nls.workflow.runs.json";
import nlsWorkflowVars from "./nls.workflow.vars.json";
export default Object.freeze({
...nlsCommon,
@ -16,8 +17,9 @@ export default Object.freeze({
...nlsSettings,
...nlsProvider,
...nlsAccess,
...nlsCertificate,
...nlsWorkflow,
...nlsWorkflowNodes,
...nlsWorkflowRuns,
...nlsCertificate,
...nlsWorkflowVars,
});

View File

@ -53,9 +53,5 @@
"workflow.detail.orchestration.action.run": "Run",
"workflow.detail.orchestration.action.run.confirm": "You have unreleased changes. Do you really want to run this workflow based on the latest released version?",
"workflow.detail.orchestration.action.run.prompt": "Running... Please check the history later",
"workflow.detail.runs.tab": "History runs",
"workflow.variables.is_validated.label": "Is valid",
"workflow.variables.days_left.label": "Days left",
"workflow.variables.certificate.label": "Certificate"
"workflow.detail.runs.tab": "History runs"
}

View File

@ -871,31 +871,32 @@
"workflow_node.end.label": "End",
"workflow_node.end.default_name": "End",
"workflow_node.branch.label": "Parallel branch",
"workflow_node.branch.default_name": "Parallel",
"workflow_node.branch.label": "Parallel/Conditional branch",
"workflow_node.branch.default_name": "Branch",
"workflow_node.condition.label": "Branch",
"workflow_node.condition.default_name": "Branch",
"workflow_node.condition.form.variable.placeholder": "Please select variable",
"workflow_node.condition.form.variable.errmsg": "Please select variable",
"workflow_node.condition.form.operator.errmsg": "Please select operator",
"workflow_node.condition.form.value.errmsg": "Please enter value",
"workflow_node.condition.form.value.string.placeholder": "Please enter value",
"workflow_node.condition.form.value.number.placeholder": "Please enter value",
"workflow_node.condition.form.value.boolean.placeholder": "Please select value",
"workflow_node.condition.form.value.boolean.true": "True",
"workflow_node.condition.form.value.boolean.false": "False",
"workflow_node.condition.form.add_condition.button": "Add condition",
"workflow_node.condition.form.logical_operator.label": "Logical operator",
"workflow_node.condition.form.logical_operator.and": "Meet all conditions (AND)",
"workflow_node.condition.form.logical_operator.or": "Meet any condition (OR)",
"workflow_node.condition.form.comparison.equal": "Equal",
"workflow_node.condition.form.comparison.not_equal": "Not equal",
"workflow_node.condition.form.comparison.greater_than": "Greater than",
"workflow_node.condition.form.comparison.greater_than_or_equal": "Greater than or equal",
"workflow_node.condition.form.comparison.less_than": "Less than",
"workflow_node.condition.form.comparison.less_than_or_equal": "Less than or equal",
"workflow_node.condition.form.comparison.is": "Is",
"workflow_node.condition.form.expression.label": "Conditions to enter the branch",
"workflow_node.condition.form.expression.logical_operator.errmsg": "Please select logical operator of conditions",
"workflow_node.condition.form.expression.logical_operator.option.and.label": "Meeting all of the conditions (AND)",
"workflow_node.condition.form.expression.logical_operator.option.or.label": "Meeting any of the conditions (OR)",
"workflow_node.condition.form.expression.variable.placeholder": "Please select",
"workflow_node.condition.form.expression.variable.errmsg": "Please select variable",
"workflow_node.condition.form.expression.operator.placeholder": "Please select",
"workflow_node.condition.form.expression.operator.errmsg": "Please select operator",
"workflow_node.condition.form.expression.operator.option.eq.label": "equal to",
"workflow_node.condition.form.expression.operator.option.eq.alias_is_label": "is",
"workflow_node.condition.form.expression.operator.option.neq.label": "not equal to",
"workflow_node.condition.form.expression.operator.option.neq.alias_not_label": "is not",
"workflow_node.condition.form.expression.operator.option.gt.label": "greater than",
"workflow_node.condition.form.expression.operator.option.gte.label": "greater than or equal to",
"workflow_node.condition.form.expression.operator.option.lt.label": "less than",
"workflow_node.condition.form.expression.operator.option.lte.label": "less than or equal to",
"workflow_node.condition.form.expression.value.placeholder": "Please enter",
"workflow_node.condition.form.expression.value.errmsg": "Please enter value",
"workflow_node.condition.form.expression.value.option.true.label": "True",
"workflow_node.condition.form.expression.value.option.false.label": "False",
"workflow_node.condition.form.expression.add_condition.button": "Add condition",
"workflow_node.execute_result_branch.label": "Execution result branch",
"workflow_node.execute_result_branch.default_name": "Execution result branch",

View File

@ -0,0 +1,6 @@
{
"workflow.variables.type.certificate.label": "Certificate",
"workflow.variables.selector.validity.label": "Validity",
"workflow.variables.selector.days_left.label": "Days left"
}

View File

@ -8,6 +8,7 @@ import nlsSettings from "./nls.settings.json";
import nlsWorkflow from "./nls.workflow.json";
import nlsWorkflowNodes from "./nls.workflow.nodes.json";
import nlsWorkflowRuns from "./nls.workflow.runs.json";
import nlsWorkflowVars from "./nls.workflow.vars.json";
export default Object.freeze({
...nlsCommon,
@ -16,8 +17,9 @@ export default Object.freeze({
...nlsSettings,
...nlsProvider,
...nlsAccess,
...nlsCertificate,
...nlsWorkflow,
...nlsWorkflowNodes,
...nlsWorkflowRuns,
...nlsCertificate,
...nlsWorkflowVars,
});

View File

@ -53,9 +53,5 @@
"workflow.detail.orchestration.action.run": "执行",
"workflow.detail.orchestration.action.run.confirm": "你有尚未发布的更改。确定要以最近一次发布的版本继续执行吗?",
"workflow.detail.orchestration.action.run.prompt": "执行中……请稍后查看执行历史",
"workflow.detail.runs.tab": "执行历史",
"workflow.variables.is_validated.label": "是否有效",
"workflow.variables.days_left.label": "剩余天数",
"workflow.variables.certificate.label": "证书"
"workflow.detail.runs.tab": "执行历史"
}

View File

@ -3,7 +3,7 @@
"workflow_node.branch.add_node": "添加节点",
"workflow_node.action.rename_node": "重命名",
"workflow_node.action.remove_node": "删除节点",
"workflow_node.action.add_branch": "添加并行分支",
"workflow_node.action.add_branch": "添加分支",
"workflow_node.action.rename_branch": "重命名",
"workflow_node.action.remove_branch": "删除分支",
@ -707,7 +707,7 @@
"workflow_node.deploy.form.ucloud_us3_domain.label": "优刻得 US3 自定义域名",
"workflow_node.deploy.form.ucloud_us3_domain.placeholder": "请输入优刻得 US3 自定义域名",
"workflow_node.deploy.form.ucloud_us3_domain.tooltip": "这是什么?请参阅 <a href=\"https://console.ucloud.cn/ufile\" target=\"_blank\">https://console.ucloud.cn/ufile</a>",
"workflow_node.deploy.form.unicloud_webhost.guide": "小贴士:由于 uniCloud 未提供相关 API这里将使用网页模拟登录方式部署但无法保证稳定性。如遇 uniCloud 接口变更,请到 GitHub 发起 Issue 告知。",
"workflow_node.deploy.form.unicloud_webhost.guide": "小贴士:由于 uniCloud 未公开相关 API这里将使用网页模拟登录方式部署但无法保证稳定性。如遇 uniCloud 接口变更,请到 GitHub 发起 Issue 告知。",
"workflow_node.deploy.form.unicloud_webhost_space_provider.label": "uniCloud 服务空间提供商",
"workflow_node.deploy.form.unicloud_webhost_space_provider.placeholder": "请选择 uniCloud 服务空间提供商",
"workflow_node.deploy.form.unicloud_webhost_space_provider.option.aliyun.label": "阿里云",
@ -717,11 +717,11 @@
"workflow_node.deploy.form.unicloud_webhost_space_id.tooltip": "这是什么?请参阅 <a href=\"https://doc.dcloud.net.cn/uniCloud/concepts/space.html\" target=\"_blank\">https://doc.dcloud.net.cn/uniCloud/concepts/space.html</a>",
"workflow_node.deploy.form.unicloud_webhost_domain.label": "uniCloud 前端网页托管网站域名",
"workflow_node.deploy.form.unicloud_webhost_domain.placeholder": "请输入 uniCloud 前端网页托管网站域名",
"workflow_node.deploy.form.upyun_cdn.guide": "小贴士:由于又拍云未提供相关 API这里将使用网页模拟登录方式部署但无法保证稳定性。如遇又拍云接口变更请到 GitHub 发起 Issue 告知。",
"workflow_node.deploy.form.upyun_cdn.guide": "小贴士:由于又拍云未公开相关 API这里将使用网页模拟登录方式部署但无法保证稳定性。如遇又拍云接口变更请到 GitHub 发起 Issue 告知。",
"workflow_node.deploy.form.upyun_cdn_domain.label": "又拍云 CDN 加速域名",
"workflow_node.deploy.form.upyun_cdn_domain.placeholder": "请输入又拍云 CDN 加速域名(支持泛域名)",
"workflow_node.deploy.form.upyun_cdn_domain.tooltip": "这是什么?请参阅 <a href=\"https://console.upyun.com/services/cdn/\" target=\"_blank\">https://console.upyun.com/services/cdn/</a>",
"workflow_node.deploy.form.upyun_file.guide": "小贴士:由于又拍云未提供相关 API这里将使用网页模拟登录方式部署但无法保证稳定性。如遇又拍云接口变更请到 GitHub 发起 Issue 告知。",
"workflow_node.deploy.form.upyun_file.guide": "小贴士:由于又拍云未公开相关 API这里将使用网页模拟登录方式部署但无法保证稳定性。如遇又拍云接口变更请到 GitHub 发起 Issue 告知。",
"workflow_node.deploy.form.upyun_file_domain.label": "又拍云云存储加速域名",
"workflow_node.deploy.form.upyun_file_domain.placeholder": "请输入又拍云云存储加速域名",
"workflow_node.deploy.form.upyun_file_domain.tooltip": "这是什么?请参阅 <a href=\"https://console.upyun.com/services/file/\" target=\"_blank\">https://console.upyun.com/services/file/</a>",
@ -870,31 +870,32 @@
"workflow_node.end.label": "结束",
"workflow_node.end.default_name": "结束",
"workflow_node.branch.label": "并行分支",
"workflow_node.branch.default_name": "并行",
"workflow_node.branch.label": "并行/条件分支",
"workflow_node.branch.default_name": "分支",
"workflow_node.condition.label": "分支",
"workflow_node.condition.default_name": "分支",
"workflow_node.condition.form.variable.placeholder": "选择变量",
"workflow_node.condition.form.variable.errmsg": "请选择变量",
"workflow_node.condition.form.operator.errmsg": "请选择操作符",
"workflow_node.condition.form.value.errmsg": "请输入值",
"workflow_node.condition.form.value.string.placeholder": "输入值",
"workflow_node.condition.form.value.number.placeholder": "输入数值",
"workflow_node.condition.form.value.boolean.placeholder": "选择值",
"workflow_node.condition.form.value.boolean.true": "是",
"workflow_node.condition.form.value.boolean.false": "否",
"workflow_node.condition.form.add_condition.button": "添加条件",
"workflow_node.condition.form.logical_operator.label": "条件逻辑",
"workflow_node.condition.form.logical_operator.and": "满足所有条件 (AND)",
"workflow_node.condition.form.logical_operator.or": "满足任一条件 (OR)",
"workflow_node.condition.form.comparison.equal": "等于",
"workflow_node.condition.form.comparison.not_equal": "不等于",
"workflow_node.condition.form.comparison.greater_than": "大于",
"workflow_node.condition.form.comparison.greater_than_or_equal": "大于等于",
"workflow_node.condition.form.comparison.less_than": "小于",
"workflow_node.condition.form.comparison.less_than_or_equal": "小于等于",
"workflow_node.condition.form.comparison.is": "为",
"workflow_node.condition.form.expression.label": "分支进入条件",
"workflow_node.condition.form.expression.logical_operator.errmsg": "请选择条件组合方式",
"workflow_node.condition.form.expression.logical_operator.option.and.label": "满足以下所有条件 (AND)",
"workflow_node.condition.form.expression.logical_operator.option.or.label": "满足以下任一条件 (OR)",
"workflow_node.condition.form.expression.variable.placeholder": "请选择",
"workflow_node.condition.form.expression.variable.errmsg": "请选择变量",
"workflow_node.condition.form.expression.operator.placeholder": "请选择",
"workflow_node.condition.form.expression.operator.errmsg": "请选择运算符",
"workflow_node.condition.form.expression.operator.option.eq.label": "等于",
"workflow_node.condition.form.expression.operator.option.eq.alias_is_label": "为",
"workflow_node.condition.form.expression.operator.option.neq.label": "不等于",
"workflow_node.condition.form.expression.operator.option.neq.alias_not_label": "不为",
"workflow_node.condition.form.expression.operator.option.gt.label": "大于",
"workflow_node.condition.form.expression.operator.option.gte.label": "大于等于",
"workflow_node.condition.form.expression.operator.option.lt.label": "小于",
"workflow_node.condition.form.expression.operator.option.lte.label": "小于等于",
"workflow_node.condition.form.expression.value.placeholder": "请输入",
"workflow_node.condition.form.expression.value.errmsg": "请输入值",
"workflow_node.condition.form.expression.value.option.true.label": "真",
"workflow_node.condition.form.expression.value.option.false.label": "假",
"workflow_node.condition.form.expression.add_condition.button": "添加条件",
"workflow_node.execute_result_branch.label": "执行结果分支",
"workflow_node.execute_result_branch.default_name": "执行结果分支",

View File

@ -0,0 +1,6 @@
{
"workflow.variables.type.certificate.label": "证书",
"workflow.variables.selector.validity.label": "有效性",
"workflow.variables.selector.days_left.label": "剩余天数"
}

View File

@ -265,7 +265,7 @@ const WorkflowDetail = () => {
body: {
position: "relative",
height: "100%",
padding: 0,
padding: initialized ? 0 : undefined,
},
}}
loading={!initialized}

View File

@ -32,7 +32,7 @@ export type WorkflowState = {
addBranch: (branchId: string) => void;
removeBranch: (branchId: string, index: number) => void;
getWorkflowOuptutBeforeId: (nodeId: string, type?: string) => WorkflowNode[];
getWorkflowOuptutBeforeId: (nodeId: string, typeFilter?: string | string[]) => WorkflowNode[];
};
export const useWorkflowStore = create<WorkflowState>((set, get) => ({
@ -243,7 +243,7 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
});
},
getWorkflowOuptutBeforeId: (nodeId: string, type: string = "all") => {
return getOutputBeforeNodeId(get().workflow.draft as WorkflowNode, nodeId, type);
getWorkflowOuptutBeforeId: (nodeId: string, typeFilter?: string | string[]) => {
return getOutputBeforeNodeId(get().workflow.draft as WorkflowNode, nodeId, typeFilter);
},
}));