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

View File

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

View File

@ -1,4 +1,4 @@
package domain package expr
import ( import (
"testing" "testing"
@ -7,15 +7,15 @@ import (
func TestLogicalEval(t *testing.T) { func TestLogicalEval(t *testing.T) {
// 测试逻辑表达式 and // 测试逻辑表达式 and
logicalExpr := LogicalExpr{ logicalExpr := LogicalExpr{
Left: ConstExpr{ Left: ConstantExpr{
Type: "const", Type: "const",
Value: true, Value: "true",
ValueType: "boolean", ValueType: "boolean",
}, },
Op: And, Operator: And,
Right: ConstExpr{ Right: ConstantExpr{
Type: "const", Type: "const",
Value: true, Value: "true",
ValueType: "boolean", ValueType: "boolean",
}, },
} }
@ -29,15 +29,15 @@ func TestLogicalEval(t *testing.T) {
// 测试逻辑表达式 or // 测试逻辑表达式 or
orExpr := LogicalExpr{ orExpr := LogicalExpr{
Left: ConstExpr{ Left: ConstantExpr{
Type: "const", Type: "const",
Value: true, Value: "true",
ValueType: "boolean", ValueType: "boolean",
}, },
Op: Or, Operator: Or,
Right: ConstExpr{ Right: ConstantExpr{
Type: "const", Type: "const",
Value: true, Value: "true",
ValueType: "boolean", ValueType: "boolean",
}, },
} }
@ -63,7 +63,7 @@ func TestUnmarshalExpr(t *testing.T) {
{ {
name: "test1", name: "test1",
args: args{ 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{ args: args{
variables: map[string]map[string]any{ variables: map[string]map[string]any{
"ODnYSOXB6HQP2_vz6JcZE": { "ODnYSOXB6HQP2_vz6JcZE": {
"certificate.validated": true, "certificate.validity": true,
"certificate.daysLeft": 2, "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" "encoding/json"
"time" "time"
"github.com/usual2970/certimate/internal/domain/expr"
maputil "github.com/usual2970/certimate/internal/pkg/utils/map" maputil "github.com/usual2970/certimate/internal/pkg/utils/map"
) )
@ -114,7 +115,7 @@ type WorkflowNodeConfigForNotify struct {
} }
type WorkflowNodeConfigForCondition struct { type WorkflowNodeConfigForCondition struct {
Expression Expr `json:"expression"` // 条件表达式 Expression expr.Expr `json:"expression"` // 条件表达式
} }
func (n *WorkflowNode) GetConfigForApply() WorkflowNodeConfigForApply { func (n *WorkflowNode) GetConfigForApply() WorkflowNodeConfigForApply {
@ -183,9 +184,8 @@ func (n *WorkflowNode) GetConfigForCondition() WorkflowNodeConfigForCondition {
return WorkflowNodeConfigForCondition{} return WorkflowNodeConfigForCondition{}
} }
raw, _ := json.Marshal(expression) exprRaw, _ := json.Marshal(expression)
expr, err := expr.UnmarshalExpr([]byte(exprRaw))
expr, err := UnmarshalExpr([]byte(raw))
if err != nil { if err != nil {
return WorkflowNodeConfigForCondition{} return WorkflowNodeConfigForCondition{}
} }
@ -204,10 +204,6 @@ type WorkflowNodeIO struct {
ValueSelector WorkflowNodeIOValueSelector `json:"valueSelector"` ValueSelector WorkflowNodeIOValueSelector `json:"valueSelector"`
} }
type WorkflowNodeIOValueSelector struct { type WorkflowNodeIOValueSelector = expr.ExprValueSelector
Id string `json:"id"`
Name string `json:"name"`
Type ValueType `json:"type"`
}
const WorkflowNodeIONameCertificate string = "certificate" 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)) return nil, fmt.Errorf("node type is not '%s'", string(domain.WorkflowNodeTypeNotify))
} }
nodeConfig := config.Node.GetConfigForNotify() nodeCfg := config.Node.GetConfigForNotify()
options := &notifierProviderOptions{ options := &notifierProviderOptions{
Provider: domain.NotificationProviderType(nodeConfig.Provider), Provider: domain.NotificationProviderType(nodeCfg.Provider),
ProviderAccessConfig: make(map[string]any), ProviderAccessConfig: make(map[string]any),
ProviderServiceConfig: nodeConfig.ProviderConfig, ProviderServiceConfig: nodeCfg.ProviderConfig,
} }
accessRepo := repository.NewAccessRepository() accessRepo := repository.NewAccessRepository()
if nodeConfig.ProviderAccessId != "" { if nodeCfg.ProviderAccessId != "" {
access, err := accessRepo.GetById(context.Background(), nodeConfig.ProviderAccessId) access, err := accessRepo.GetById(context.Background(), nodeCfg.ProviderAccessId)
if err != nil { 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 { } else {
options.ProviderAccessConfig = access.Config options.ProviderAccessConfig = access.Config
} }

View File

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

View File

@ -13,6 +13,7 @@ import (
"github.com/luthermonson/go-proxmox" "github.com/luthermonson/go-proxmox"
"github.com/usual2970/certimate/internal/pkg/core/deployer" "github.com/usual2970/certimate/internal/pkg/core/deployer"
httputil "github.com/usual2970/certimate/internal/pkg/utils/http"
) )
type DeployerConfig struct { type DeployerConfig struct {
@ -101,15 +102,16 @@ func createSdkClient(serverUrl, apiToken, apiTokenSecret string, skipTlsVerify b
} }
httpClient := &http.Client{ httpClient := &http.Client{
Transport: http.DefaultTransport, Transport: httputil.NewDefaultTransport(),
Timeout: http.DefaultClient.Timeout, Timeout: http.DefaultClient.Timeout,
} }
if skipTlsVerify { if skipTlsVerify {
httpClient.Transport = &http.Transport{ transport := httputil.NewDefaultTransport()
TLSClientConfig: &tls.Config{ if transport.TLSClientConfig == nil {
InsecureSkipVerify: true, transport.TLSClientConfig = &tls.Config{}
},
} }
transport.TLSClientConfig.InsecureSkipVerify = true
httpClient.Transport = transport
} }
client := proxmox.NewClient( client := proxmox.NewClient(
strings.TrimRight(serverUrl, "/")+"/api2/json", 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() listCertificatesResp, err := u.sdkClient.ListCertificates()
u.logger.Debug("sdk request 'certificatemanagement.ListCertificates'", slog.Any("response", listCertificatesResp)) u.logger.Debug("sdk request 'certificatemanagement.ListCertificates'", slog.Any("response", listCertificatesResp))
if err != nil { 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 ( import (
"context" "context"
"fmt" "fmt"
"strconv"
"time" "time"
"golang.org/x/exp/maps" "golang.org/x/exp/maps"
@ -108,15 +109,15 @@ func (n *applyNode) Process(ctx context.Context) error {
} }
} }
// 添加中间结果 // 记录中间结果
n.outputs[outputCertificateValidatedKey] = "true" n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(true)
n.outputs[outputCertificateDaysLeftKey] = fmt.Sprintf("%d", int(time.Until(certificate.ExpireAt).Hours()/24)) n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(time.Until(certificate.ExpireAt).Hours()/24), 10)
n.logger.Info("application completed") n.logger.Info("application completed")
return nil 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 { if lastOutput != nil && lastOutput.Succeeded {
// 比较和上次申请时的关键配置(即影响证书签发的)参数是否一致 // 比较和上次申请时的关键配置(即影响证书签发的)参数是否一致
currentNodeConfig := n.node.GetConfigForApply() 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 renewalInterval := time.Duration(currentNodeConfig.SkipBeforeExpiryDays) * time.Hour * 24
expirationTime := time.Until(lastCertificate.ExpireAt) expirationTime := time.Until(lastCertificate.ExpireAt)
if expirationTime > renewalInterval { if expirationTime > renewalInterval {
n.outputs[outputCertificateValidatedKey] = "true" daysLeft := int(expirationTime.Hours() / 24)
n.outputs[outputCertificateDaysLeftKey] = fmt.Sprintf("%d", int(expirationTime.Hours()/24)) // TODO: 优化此处逻辑,[checkCanSkip] 方法不应该修改中间结果,违背单一职责
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) 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 ( import (
"context" "context"
"errors" "errors"
"fmt"
"github.com/usual2970/certimate/internal/domain" "github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/domain/expr"
) )
type conditionNode struct { type conditionNode struct {
@ -22,30 +24,29 @@ func NewConditionNode(node *domain.WorkflowNode) *conditionNode {
} }
func (n *conditionNode) Process(ctx context.Context) error { func (n *conditionNode) Process(ctx context.Context) error {
n.logger.Info("enter condition node: " + n.node.Name) nodeCfg := n.node.GetConfigForCondition()
if nodeCfg.Expression == nil {
nodeConfig := n.node.GetConfigForCondition() n.logger.Info("without any conditions, enter this branch")
if nodeConfig.Expression == nil {
n.logger.Info("no condition found, continue to next node")
return nil return nil
} }
rs, err := n.eval(ctx, nodeConfig.Expression) rs, err := n.evalExpr(ctx, nodeCfg.Expression)
if err != nil { 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 return err
} }
if rs.Value == false { if rs.Value == false {
n.logger.Info("condition not met, skip this branch") 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 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) variables := GetNodeOutputs(ctx)
return expression.Eval(variables) return expression.Eval(variables)
} }

View File

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

View File

@ -35,7 +35,8 @@ func AddNodeOutput(ctx context.Context, nodeId string, output map[string]any) co
container.Lock() container.Lock()
defer container.Unlock() defer container.Unlock()
// 创建输出的深拷贝以避免后续修改 // 创建输出的深拷贝
// TODO: 暂时使用浅拷贝,等后续值类型扩充后修改
outputCopy := make(map[string]any, len(output)) outputCopy := make(map[string]any, len(output))
for k, v := range output { for k, v := range output {
outputCopy[k] = v outputCopy[k] = v
@ -90,6 +91,7 @@ func GetNodeOutputs(ctx context.Context) map[string]map[string]any {
defer container.RUnlock() defer container.RUnlock()
// 创建所有输出的深拷贝 // 创建所有输出的深拷贝
// TODO: 暂时使用浅拷贝,等后续值类型扩充后修改
allOutputs := make(map[string]map[string]any, len(container.outputs)) allOutputs := make(map[string]map[string]any, len(container.outputs))
for nodeId, output := range container.outputs { for nodeId, output := range container.outputs {
nodeCopy := make(map[string]any, len(output)) 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 previousNodeOutputCertificateSource := n.node.GetConfigForDeploy().Certificate
previousNodeOutputCertificateSourceSlice := strings.Split(previousNodeOutputCertificateSource, "#") previousNodeOutputCertificateSourceSlice := strings.Split(previousNodeOutputCertificateSource, DELIMITER)
if len(previousNodeOutputCertificateSourceSlice) != 2 { if len(previousNodeOutputCertificateSourceSlice) != 2 {
n.logger.Warn("invalid certificate source", slog.String("certificate.source", previousNodeOutputCertificateSource)) n.logger.Warn("invalid certificate source", slog.String("certificate.source", previousNodeOutputCertificateSource))
return fmt.Errorf("invalid certificate source: %s", previousNodeOutputCertificateSource) return fmt.Errorf("invalid certificate source: %s", previousNodeOutputCertificateSource)
@ -99,7 +100,7 @@ func (n *deployNode) Process(ctx context.Context) error {
return nil 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 { if lastOutput != nil && lastOutput.Succeeded {
// 比较和上次部署时的关键配置(即影响证书部署的)参数是否一致 // 比较和上次部署时的关键配置(即影响证书部署的)参数是否一致
currentNodeConfig := n.node.GetConfigForDeploy() currentNodeConfig := n.node.GetConfigForDeploy()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -265,7 +265,7 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues
})} })}
/> />
) : null} ) : 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")} {t("access.form.ssh_jump_servers.add")}
</Button> </Button>
</Space> </Space>

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; 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 { type AccessModel } from "@/domain/access";
import { accessProvidersMap } from "@/domain/provider"; import { accessProvidersMap } from "@/domain/provider";
@ -14,6 +14,8 @@ export type AccessTypeSelectProps = Omit<
}; };
const AccessSelect = ({ filter, ...props }: AccessTypeSelectProps) => { const AccessSelect = ({ filter, ...props }: AccessTypeSelectProps) => {
const { token: themeToken } = theme.useToken();
const { accesses, loadedAtOnce, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "loadedAtOnce", "fetchAccesses"])); const { accesses, loadedAtOnce, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "loadedAtOnce", "fetchAccesses"]));
useEffect(() => { useEffect(() => {
fetchAccesses(); fetchAccesses();
@ -65,12 +67,12 @@ const AccessSelect = ({ filter, ...props }: AccessTypeSelectProps) => {
const value = inputValue.toLowerCase(); const value = inputValue.toLowerCase();
return option.label.toLowerCase().includes(value); return option.label.toLowerCase().includes(value);
}} }}
labelRender={({ label, value }) => { labelRender={({ value }) => {
if (label) { if (value != null) {
return renderOption(value as string); 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} loading={!loadedAtOnce}
options={options} options={options}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -36,7 +36,7 @@ import { ClientResponseError } from "pocketbase";
import CertificateDetailDrawer from "@/components/certificate/CertificateDetailDrawer"; import CertificateDetailDrawer from "@/components/certificate/CertificateDetailDrawer";
import Show from "@/components/Show"; import Show from "@/components/Show";
import { type CertificateModel } from "@/domain/certificate"; 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 { WORKFLOW_RUN_STATUSES, type WorkflowRunModel } from "@/domain/workflowRun";
import { useBrowserTheme } from "@/hooks"; import { useBrowserTheme } from "@/hooks";
import { listByWorkflowRunId as listCertificatesByWorkflowRunId } from "@/repository/certificate"; 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 />], [WorkflowNodeType.ExecuteResultBranch, "workflow_node.execute_result_branch.label", <SisternodeOutlinedIcon />],
] ]
.filter(([type]) => { .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; return type !== WorkflowNodeType.ExecuteResultBranch;
} }

View File

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

View File

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

View File

@ -1,17 +1,14 @@
import { memo, useRef, useState } from "react"; 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 { Button, Card, Popover } from "antd";
import { produce } from "immer"; import { produce } from "immer";
import type { Expr, WorkflowNodeIoValueType } from "@/domain/workflow";
import { ExprType } from "@/domain/workflow";
import { useZustandShallowSelector } from "@/hooks"; import { useZustandShallowSelector } from "@/hooks";
import { useWorkflowStore } from "@/stores/workflow"; import { useWorkflowStore } from "@/stores/workflow";
import SharedNode, { type SharedNodeProps } from "./_SharedNode"; import SharedNode, { type SharedNodeProps } from "./_SharedNode";
import AddNode from "./AddNode"; import AddNode from "./AddNode";
import type { ConditionItem, ConditionNodeConfigFormFieldValues, ConditionNodeConfigFormInstance } from "./ConditionNodeConfigForm"; import ConditionNodeConfigForm, { type ConditionNodeConfigFormFieldValues, type ConditionNodeConfigFormInstance } from "./ConditionNodeConfigForm";
import ConditionNodeConfigForm from "./ConditionNodeConfigForm";
export type ConditionNodeProps = SharedNodeProps & { export type ConditionNodeProps = SharedNodeProps & {
branchId: string; branchId: string;
@ -23,55 +20,9 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP
const [formPending, setFormPending] = useState(false); const [formPending, setFormPending] = useState(false);
const formRef = useRef<ConditionNodeConfigFormInstance>(null); const formRef = useRef<ConditionNodeConfigFormInstance>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const getFormValues = () => formRef.current!.getFieldsValue() as ConditionNodeConfigFormFieldValues; const getFormValues = () => formRef.current!.getFieldsValue() as ConditionNodeConfigFormFieldValues;
// 将表单值转换为表达式结构 const [drawerOpen, setDrawerOpen] = useState(false);
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 handleDrawerConfirm = async () => { const handleDrawerConfirm = async () => {
setFormPending(true); setFormPending(true);
@ -84,10 +35,9 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP
try { try {
const newValues = getFormValues(); const newValues = getFormValues();
const expression = formToExpression(newValues);
const newNode = produce(node, (draft) => { const newNode = produce(node, (draft) => {
draft.config = { draft.config = {
expression, ...newValues,
}; };
draft.validated = true; draft.validated = true;
}); });
@ -100,7 +50,7 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP
return ( return (
<> <>
<Popover <Popover
classNames={{ root: "shadow-md" }} classNames={{ root: "mt-20 shadow-md" }}
styles={{ body: { padding: 0 } }} styles={{ body: { padding: 0 } }}
arrow={false} arrow={false}
content={ 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)}> <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="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 <SharedNode.Title
className="focus:bg-background focus:text-foreground overflow-hidden outline-slate-200 focus:rounded-sm" className="focus:bg-background focus:text-foreground overflow-hidden outline-slate-200 focus:rounded-sm"
node={node} node={node}
disabled={disabled} 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> </div>
</Card> </Card>
</Popover>
<SharedNode.ConfigDrawer <SharedNode.ConfigDrawer
getConfigNewValues={getFormValues}
node={node} node={node}
open={drawerOpen} open={drawerOpen}
pending={formPending} pending={formPending}
onConfirm={handleDrawerConfirm} onConfirm={handleDrawerConfirm}
onOpenChange={(open) => setDrawerOpen(open)} onOpenChange={(open) => setDrawerOpen(open)}
getFormValues={() => formRef.current!.getFieldsValue()}
> >
<ConditionNodeConfigForm nodeId={node.id} ref={formRef} disabled={disabled} initialValues={node.config} /> <ConditionNodeConfigForm nodeId={node.id} ref={formRef} disabled={disabled} initialValues={node.config} />
</SharedNode.ConfigDrawer> </SharedNode.ConfigDrawer>
</Popover>
<AddNode node={node} disabled={disabled} /> <AddNode node={node} disabled={disabled} />
</> </>

View File

@ -1,36 +1,16 @@
import { forwardRef, memo, useEffect, useImperativeHandle, useState } from "react"; import { forwardRef, memo, useImperativeHandle, useRef } 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 { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Form, type FormInstance } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
// 表单内部使用的扁平结构 - 修改后只保留必要字段 import { type Expr, type WorkflowNodeConfigForCondition } from "@/domain/workflow";
export interface ConditionItem { import { useAntdForm } from "@/hooks";
leftSelector: string;
operator: ComparisonOperator; import ConditionNodeConfigFormExpressionEditor, { type ConditionNodeConfigFormExpressionEditorInstance } from "./ConditionNodeConfigFormExpressionEditor";
rightValue: string;
}
export type ConditionNodeConfigFormFieldValues = { export type ConditionNodeConfigFormFieldValues = {
conditions: ConditionItem[]; expression?: Expr | undefined;
logicalOperator: LogicalOperator;
}; };
export type ConditionNodeConfigFormProps = { export type ConditionNodeConfigFormProps = {
@ -38,9 +18,8 @@ export type ConditionNodeConfigFormProps = {
style?: React.CSSProperties; style?: React.CSSProperties;
disabled?: boolean; disabled?: boolean;
initialValues?: Partial<WorkflowNodeConfigForCondition>; initialValues?: Partial<WorkflowNodeConfigForCondition>;
onValuesChange?: (values: WorkflowNodeConfigForCondition) => void;
availableSelectors?: WorkflowNodeIOValueSelector[];
nodeId: string; nodeId: string;
onValuesChange?: (values: WorkflowNodeConfigForCondition) => void;
}; };
export type ConditionNodeConfigFormInstance = { export type ConditionNodeConfigFormInstance = {
@ -49,298 +28,49 @@ export type ConditionNodeConfigFormInstance = {
validateFields: FormInstance<ConditionNodeConfigFormFieldValues>["validateFields"]; validateFields: FormInstance<ConditionNodeConfigFormFieldValues>["validateFields"];
}; };
// 初始表单值
const initFormModel = (): ConditionNodeConfigFormFieldValues => { const initFormModel = (): ConditionNodeConfigFormFieldValues => {
return { 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";
}; };
const ConditionNodeConfigForm = forwardRef<ConditionNodeConfigFormInstance, ConditionNodeConfigFormProps>( const ConditionNodeConfigForm = forwardRef<ConditionNodeConfigFormInstance, ConditionNodeConfigFormProps>(
({ className, style, disabled, initialValues, onValuesChange, nodeId }, ref) => { ({ className, style, disabled, initialValues, nodeId, onValuesChange }, ref) => {
const { t } = useTranslation(); 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 editorRef = useRef<ConditionNodeConfigFormExpressionEditorInstance>(null);
const [formModel, setFormModel] = useState<ConditionNodeConfigFormFieldValues>(initFormModel());
const [previousNodes, setPreviousNodes] = useState<WorkflowNode[]>([]); const handleFormChange = (_: unknown, values: z.infer<typeof formSchema>) => {
useEffect(() => { onValuesChange?.(values);
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 });
}
}; };
return ( useImperativeHandle(ref, () => {
<Form form={form} className={className} style={style} layout="vertical" disabled={disabled} initialValues={formModel} onValuesChange={handleFormChange}> return {
<Form.List name="conditions"> getFieldsValue: formInst.getFieldsValue,
{(fields, { add, remove }) => ( resetFields: formInst.resetFields,
<> validateFields: (nameList, config) => {
{fields.map(({ key, name, ...restField }) => ( const t1 = formInst.validateFields(nameList, config);
<Card const t2 = editorRef.current!.validate();
key={key} return Promise.all([t1, t2]).then(() => t1);
size="small" },
className="mb-3" } as ConditionNodeConfigFormInstance;
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);
return ( return (
<Form.Item <Form className={className} style={style} {...formProps} disabled={disabled} layout="vertical" scrollToFirstError onValuesChange={handleFormChange}>
{...restField} <Form.Item name="expression" label={t("workflow_node.condition.form.expression.label")} rules={[formRule]}>
name={[name, "operator"]} <ConditionNodeConfigFormExpressionEditor ref={editorRef} nodeId={nodeId} />
className="mb-0 w-32"
rules={[{ required: true, message: t(`${prefix}.operator.errmsg`) }]}
>
<Select options={operators} />
</Form.Item> </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> </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); 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 formRef = useRef<DeployNodeConfigFormInstance>(null);
const [formPending, setFormPending] = useState(false); const [formPending, setFormPending] = useState(false);
const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForDeploy;
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [drawerFooterShow, setDrawerFooterShow] = useState(true); const [drawerFooterShow, setDrawerFooterShow] = useState(true);
const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForDeploy;
useEffect(() => { useEffect(() => {
setDrawerFooterShow(!!(node.config as WorkflowNodeConfigForDeploy)?.provider); setDrawerFooterShow(!!(node.config as WorkflowNodeConfigForDeploy)?.provider);
@ -86,8 +86,9 @@ const DeployNode = ({ node, disabled }: DeployNodeProps) => {
</SharedNode.Block> </SharedNode.Block>
<SharedNode.ConfigDrawer <SharedNode.ConfigDrawer
node={node}
footer={drawerFooterShow} footer={drawerFooterShow}
getConfigNewValues={getFormValues}
node={node}
open={drawerOpen} open={drawerOpen}
pending={formPending} pending={formPending}
onConfirm={handleDrawerConfirm} onConfirm={handleDrawerConfirm}
@ -95,7 +96,6 @@ const DeployNode = ({ node, disabled }: DeployNodeProps) => {
setDrawerFooterShow(!!(node.config as WorkflowNodeConfigForDeploy)?.provider); setDrawerFooterShow(!!(node.config as WorkflowNodeConfigForDeploy)?.provider);
setDrawerOpen(open); setDrawerOpen(open);
}} }}
getFormValues={() => formRef.current!.getFieldsValue()}
> >
<DeployNodeConfigForm ref={formRef} disabled={disabled} initialValues={node.config} nodeId={node.id} onValuesChange={handleFormValuesChange} /> <DeployNodeConfigForm ref={formRef} disabled={disabled} initialValues={node.config} nodeId={node.id} onValuesChange={handleFormValuesChange} />
</SharedNode.ConfigDrawer> </SharedNode.ConfigDrawer>

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next"; 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 { createSchemaFieldRule } from "antd-zod";
import { z } from "zod"; import { z } from "zod";
@ -56,6 +56,10 @@ const DeployNodeConfigFormUniCloudWebHostConfig = ({
name={formName} name={formName}
onValuesChange={handleFormChange} 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]}> <Form.Item name="spaceProvider" label={t("workflow_node.deploy.form.unicloud_webhost_space_provider.label")} rules={[formRule]}>
<Select <Select
options={["aliyun", "tencent"].map((s) => ({ options={["aliyun", "tencent"].map((s) => ({

View File

@ -18,7 +18,7 @@ export type DeployNodeConfigFormWangsuCDNConfigProps = {
onValuesChange?: (values: DeployNodeConfigFormWangsuCDNConfigFieldValues) => void; onValuesChange?: (values: DeployNodeConfigFormWangsuCDNConfigFieldValues) => void;
}; };
const MULTIPLE_INPUT_DELIMITER = ";"; const MULTIPLE_INPUT_SEPARATOR = ";";
const initFormModel = (): DeployNodeConfigFormWangsuCDNConfigFieldValues => { const initFormModel = (): DeployNodeConfigFormWangsuCDNConfigFieldValues => {
return { return {
@ -42,7 +42,7 @@ const DeployNodeConfigFormWangsuCDNConfig = ({
.refine((v) => { .refine((v) => {
if (!v) return false; if (!v) return false;
return String(v) return String(v)
.split(MULTIPLE_INPUT_DELIMITER) .split(MULTIPLE_INPUT_SEPARATOR)
.every((e) => validDomainName(e)); .every((e) => validDomainName(e));
}, t("workflow_node.deploy.form.wangsu_cdn_domains.placeholder")), }, 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 formRef = useRef<MonitorNodeConfigFormInstance>(null);
const [formPending, setFormPending] = useState(false); const [formPending, setFormPending] = useState(false);
const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForMonitor;
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForMonitor;
const wrappedEl = useMemo(() => { const wrappedEl = useMemo(() => {
if (node.type !== WorkflowNodeType.Monitor) { if (node.type !== WorkflowNodeType.Monitor) {
@ -74,12 +74,12 @@ const MonitorNode = ({ node, disabled }: MonitorNodeProps) => {
</SharedNode.Block> </SharedNode.Block>
<SharedNode.ConfigDrawer <SharedNode.ConfigDrawer
getConfigNewValues={getFormValues}
node={node} node={node}
open={drawerOpen} open={drawerOpen}
pending={formPending} pending={formPending}
onConfirm={handleDrawerConfirm} onConfirm={handleDrawerConfirm}
onOpenChange={(open) => setDrawerOpen(open)} onOpenChange={(open) => setDrawerOpen(open)}
getFormValues={() => formRef.current!.getFieldsValue()}
> >
<MonitorNodeConfigForm ref={formRef} disabled={disabled} initialValues={node.config} /> <MonitorNodeConfigForm ref={formRef} disabled={disabled} initialValues={node.config} />
</SharedNode.ConfigDrawer> </SharedNode.ConfigDrawer>

View File

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

View File

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

View File

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

View File

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

View File

@ -69,7 +69,7 @@ const workflowNodeTypeDefaultInputs: Map<WorkflowNodeType, WorkflowNodeIO[]> = n
name: "certificate", name: "certificate",
type: "certificate", type: "certificate",
required: true, 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", name: "certificate",
type: "certificate", type: "certificate",
required: true, 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", name: "certificate",
type: "certificate", type: "certificate",
required: true, 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", name: "certificate",
type: "certificate", type: "certificate",
required: true, 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 = { export type WorkflowNodeConfigForCondition = {
expression: Expr; expression?: Expr;
}; };
export type WorkflowNodeConfigForBranch = never; export type WorkflowNodeConfigForBranch = never;
@ -204,96 +204,35 @@ export type WorkflowNodeIO = {
valueSelector?: WorkflowNodeIOValueSelector; valueSelector?: WorkflowNodeIOValueSelector;
}; };
export const VALUE_TYPES = Object.freeze({ export type WorkflowNodeIOValueSelector = ExprValueSelector;
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;
};
// #endregion // #endregion
// #region Condition expression // #region Expression
export type Value = string | number | boolean;
export type ComparisonOperator = ">" | "<" | ">=" | "<=" | "==" | "!=" | "is";
export enum LogicalOperator {
And = "and",
Or = "or",
Not = "not",
}
export enum ExprType { export enum ExprType {
Const = "const", Constant = "const",
Var = "var", Variant = "var",
Compare = "compare", Comparison = "comparison",
Logical = "logical", Logical = "logical",
Not = "not", Not = "not",
} }
export type ConstExpr = { type: ExprType.Const; value: string; valueType: WorkflowNodeIoValueType }; export type ExprValue = string | number | boolean;
export type VarExpr = { type: ExprType.Var; selector: WorkflowNodeIOValueSelector }; export type ExprValueType = "string" | "number" | "boolean";
export type CompareExpr = { type: ExprType.Compare; op: ComparisonOperator; left: Expr; right: Expr }; export type ExprValueSelector = {
export type LogicalExpr = { type: ExprType.Logical; op: LogicalOperator; left: Expr; right: Expr }; 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 NotExpr = { type: ExprType.Not; expr: Expr };
export type Expr = ConstantExpr | VariantExpr | ComparisonExpr | LogicalExpr | NotExpr;
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;
};
// #endregion // #endregion
const isBranchLike = (node: WorkflowNode) => { const isBranchLike = (node: WorkflowNode) => {
@ -352,8 +291,8 @@ export const newNode = (nodeType: WorkflowNodeType, options: NewNodeOptions = {}
switch (nodeType) { switch (nodeType) {
case WorkflowNodeType.Apply: case WorkflowNodeType.Apply:
case WorkflowNodeType.Upload: case WorkflowNodeType.Upload:
case WorkflowNodeType.Deploy:
case WorkflowNodeType.Monitor: case WorkflowNodeType.Monitor:
case WorkflowNodeType.Deploy:
{ {
node.inputs = workflowNodeTypeDefaultInputs.get(nodeType); node.inputs = workflowNodeTypeDefaultInputs.get(nodeType);
node.outputs = workflowNodeTypeDefaultOutputs.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) => { export const getOutputBeforeNodeId = (root: WorkflowNode, nodeId: string, typeFilter?: string | string[]): WorkflowNode[] => {
if (t === "all") {
return true;
}
if (a.type === t) {
return true;
}
return false;
};
export const getOutputBeforeNodeId = (root: WorkflowNode, nodeId: string, type: string = "all"): WorkflowNode[] => {
// 某个分支的节点,不应该能获取到相邻分支上节点的输出 // 某个分支的节点,不应该能获取到相邻分支上节点的输出
const outputs: 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[]) => { const traverse = (current: WorkflowNode, output: WorkflowNode[]) => {
if (!current) { if (!current) {
return false; return false;
@ -567,10 +510,10 @@ export const getOutputBeforeNodeId = (root: WorkflowNode, nodeId: string, type:
return true; 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({ output.push({
...current, ...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 nlsWorkflow from "./nls.workflow.json";
import nlsWorkflowNodes from "./nls.workflow.nodes.json"; import nlsWorkflowNodes from "./nls.workflow.nodes.json";
import nlsWorkflowRuns from "./nls.workflow.runs.json"; import nlsWorkflowRuns from "./nls.workflow.runs.json";
import nlsWorkflowVars from "./nls.workflow.vars.json";
export default Object.freeze({ export default Object.freeze({
...nlsCommon, ...nlsCommon,
@ -16,8 +17,9 @@ export default Object.freeze({
...nlsSettings, ...nlsSettings,
...nlsProvider, ...nlsProvider,
...nlsAccess, ...nlsAccess,
...nlsCertificate,
...nlsWorkflow, ...nlsWorkflow,
...nlsWorkflowNodes, ...nlsWorkflowNodes,
...nlsWorkflowRuns, ...nlsWorkflowRuns,
...nlsCertificate, ...nlsWorkflowVars,
}); });

View File

@ -53,9 +53,5 @@
"workflow.detail.orchestration.action.run": "Run", "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.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.orchestration.action.run.prompt": "Running... Please check the history later",
"workflow.detail.runs.tab": "History runs", "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"
} }

View File

@ -871,31 +871,32 @@
"workflow_node.end.label": "End", "workflow_node.end.label": "End",
"workflow_node.end.default_name": "End", "workflow_node.end.default_name": "End",
"workflow_node.branch.label": "Parallel branch", "workflow_node.branch.label": "Parallel/Conditional branch",
"workflow_node.branch.default_name": "Parallel", "workflow_node.branch.default_name": "Branch",
"workflow_node.condition.label": "Branch", "workflow_node.condition.label": "Branch",
"workflow_node.condition.default_name": "Branch", "workflow_node.condition.default_name": "Branch",
"workflow_node.condition.form.variable.placeholder": "Please select variable", "workflow_node.condition.form.expression.label": "Conditions to enter the branch",
"workflow_node.condition.form.variable.errmsg": "Please select variable", "workflow_node.condition.form.expression.logical_operator.errmsg": "Please select logical operator of conditions",
"workflow_node.condition.form.operator.errmsg": "Please select operator", "workflow_node.condition.form.expression.logical_operator.option.and.label": "Meeting all of the conditions (AND)",
"workflow_node.condition.form.value.errmsg": "Please enter value", "workflow_node.condition.form.expression.logical_operator.option.or.label": "Meeting any of the conditions (OR)",
"workflow_node.condition.form.value.string.placeholder": "Please enter value", "workflow_node.condition.form.expression.variable.placeholder": "Please select",
"workflow_node.condition.form.value.number.placeholder": "Please enter value", "workflow_node.condition.form.expression.variable.errmsg": "Please select variable",
"workflow_node.condition.form.value.boolean.placeholder": "Please select value", "workflow_node.condition.form.expression.operator.placeholder": "Please select",
"workflow_node.condition.form.value.boolean.true": "True", "workflow_node.condition.form.expression.operator.errmsg": "Please select operator",
"workflow_node.condition.form.value.boolean.false": "False", "workflow_node.condition.form.expression.operator.option.eq.label": "equal to",
"workflow_node.condition.form.add_condition.button": "Add condition", "workflow_node.condition.form.expression.operator.option.eq.alias_is_label": "is",
"workflow_node.condition.form.logical_operator.label": "Logical operator", "workflow_node.condition.form.expression.operator.option.neq.label": "not equal to",
"workflow_node.condition.form.logical_operator.and": "Meet all conditions (AND)", "workflow_node.condition.form.expression.operator.option.neq.alias_not_label": "is not",
"workflow_node.condition.form.logical_operator.or": "Meet any condition (OR)", "workflow_node.condition.form.expression.operator.option.gt.label": "greater than",
"workflow_node.condition.form.comparison.equal": "Equal", "workflow_node.condition.form.expression.operator.option.gte.label": "greater than or equal to",
"workflow_node.condition.form.comparison.not_equal": "Not equal", "workflow_node.condition.form.expression.operator.option.lt.label": "less than",
"workflow_node.condition.form.comparison.greater_than": "Greater than", "workflow_node.condition.form.expression.operator.option.lte.label": "less than or equal to",
"workflow_node.condition.form.comparison.greater_than_or_equal": "Greater than or equal", "workflow_node.condition.form.expression.value.placeholder": "Please enter",
"workflow_node.condition.form.comparison.less_than": "Less than", "workflow_node.condition.form.expression.value.errmsg": "Please enter value",
"workflow_node.condition.form.comparison.less_than_or_equal": "Less than or equal", "workflow_node.condition.form.expression.value.option.true.label": "True",
"workflow_node.condition.form.comparison.is": "Is", "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.label": "Execution result branch",
"workflow_node.execute_result_branch.default_name": "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 nlsWorkflow from "./nls.workflow.json";
import nlsWorkflowNodes from "./nls.workflow.nodes.json"; import nlsWorkflowNodes from "./nls.workflow.nodes.json";
import nlsWorkflowRuns from "./nls.workflow.runs.json"; import nlsWorkflowRuns from "./nls.workflow.runs.json";
import nlsWorkflowVars from "./nls.workflow.vars.json";
export default Object.freeze({ export default Object.freeze({
...nlsCommon, ...nlsCommon,
@ -16,8 +17,9 @@ export default Object.freeze({
...nlsSettings, ...nlsSettings,
...nlsProvider, ...nlsProvider,
...nlsAccess, ...nlsAccess,
...nlsCertificate,
...nlsWorkflow, ...nlsWorkflow,
...nlsWorkflowNodes, ...nlsWorkflowNodes,
...nlsWorkflowRuns, ...nlsWorkflowRuns,
...nlsCertificate, ...nlsWorkflowVars,
}); });

View File

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

View File

@ -3,7 +3,7 @@
"workflow_node.branch.add_node": "添加节点", "workflow_node.branch.add_node": "添加节点",
"workflow_node.action.rename_node": "重命名", "workflow_node.action.rename_node": "重命名",
"workflow_node.action.remove_node": "删除节点", "workflow_node.action.remove_node": "删除节点",
"workflow_node.action.add_branch": "添加并行分支", "workflow_node.action.add_branch": "添加分支",
"workflow_node.action.rename_branch": "重命名", "workflow_node.action.rename_branch": "重命名",
"workflow_node.action.remove_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.label": "优刻得 US3 自定义域名",
"workflow_node.deploy.form.ucloud_us3_domain.placeholder": "请输入优刻得 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.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.label": "uniCloud 服务空间提供商",
"workflow_node.deploy.form.unicloud_webhost_space_provider.placeholder": "请选择 uniCloud 服务空间提供商", "workflow_node.deploy.form.unicloud_webhost_space_provider.placeholder": "请选择 uniCloud 服务空间提供商",
"workflow_node.deploy.form.unicloud_webhost_space_provider.option.aliyun.label": "阿里云", "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_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.label": "uniCloud 前端网页托管网站域名",
"workflow_node.deploy.form.unicloud_webhost_domain.placeholder": "请输入 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.label": "又拍云 CDN 加速域名",
"workflow_node.deploy.form.upyun_cdn_domain.placeholder": "请输入又拍云 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_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.label": "又拍云云存储加速域名",
"workflow_node.deploy.form.upyun_file_domain.placeholder": "请输入又拍云云存储加速域名", "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>", "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.label": "结束",
"workflow_node.end.default_name": "结束", "workflow_node.end.default_name": "结束",
"workflow_node.branch.label": "并行分支", "workflow_node.branch.label": "并行/条件分支",
"workflow_node.branch.default_name": "并行", "workflow_node.branch.default_name": "分支",
"workflow_node.condition.label": "分支", "workflow_node.condition.label": "分支",
"workflow_node.condition.default_name": "分支", "workflow_node.condition.default_name": "分支",
"workflow_node.condition.form.variable.placeholder": "选择变量", "workflow_node.condition.form.expression.label": "分支进入条件",
"workflow_node.condition.form.variable.errmsg": "请选择变量", "workflow_node.condition.form.expression.logical_operator.errmsg": "请选择条件组合方式",
"workflow_node.condition.form.operator.errmsg": "请选择操作符", "workflow_node.condition.form.expression.logical_operator.option.and.label": "满足以下所有条件 (AND)",
"workflow_node.condition.form.value.errmsg": "请输入值", "workflow_node.condition.form.expression.logical_operator.option.or.label": "满足以下任一条件 (OR)",
"workflow_node.condition.form.value.string.placeholder": "输入值", "workflow_node.condition.form.expression.variable.placeholder": "请选择",
"workflow_node.condition.form.value.number.placeholder": "输入数值", "workflow_node.condition.form.expression.variable.errmsg": "请选择变量",
"workflow_node.condition.form.value.boolean.placeholder": "选择值", "workflow_node.condition.form.expression.operator.placeholder": "请选择",
"workflow_node.condition.form.value.boolean.true": "是", "workflow_node.condition.form.expression.operator.errmsg": "请选择运算符",
"workflow_node.condition.form.value.boolean.false": "否", "workflow_node.condition.form.expression.operator.option.eq.label": "等于",
"workflow_node.condition.form.add_condition.button": "添加条件", "workflow_node.condition.form.expression.operator.option.eq.alias_is_label": "为",
"workflow_node.condition.form.logical_operator.label": "条件逻辑", "workflow_node.condition.form.expression.operator.option.neq.label": "不等于",
"workflow_node.condition.form.logical_operator.and": "满足所有条件 (AND)", "workflow_node.condition.form.expression.operator.option.neq.alias_not_label": "不为",
"workflow_node.condition.form.logical_operator.or": "满足任一条件 (OR)", "workflow_node.condition.form.expression.operator.option.gt.label": "大于",
"workflow_node.condition.form.comparison.equal": "等于", "workflow_node.condition.form.expression.operator.option.gte.label": "大于等于",
"workflow_node.condition.form.comparison.not_equal": "不等于", "workflow_node.condition.form.expression.operator.option.lt.label": "小于",
"workflow_node.condition.form.comparison.greater_than": "大于", "workflow_node.condition.form.expression.operator.option.lte.label": "小于等于",
"workflow_node.condition.form.comparison.greater_than_or_equal": "大于等于", "workflow_node.condition.form.expression.value.placeholder": "请输入",
"workflow_node.condition.form.comparison.less_than": "小于", "workflow_node.condition.form.expression.value.errmsg": "请输入值",
"workflow_node.condition.form.comparison.less_than_or_equal": "小于等于", "workflow_node.condition.form.expression.value.option.true.label": "真",
"workflow_node.condition.form.comparison.is": "为", "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.label": "执行结果分支",
"workflow_node.execute_result_branch.default_name": "执行结果分支", "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: { body: {
position: "relative", position: "relative",
height: "100%", height: "100%",
padding: 0, padding: initialized ? 0 : undefined,
}, },
}} }}
loading={!initialized} loading={!initialized}

View File

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