From 476877991ca23aa7a70c18effc96346e73bd6e0a Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Thu, 4 Sep 2025 09:46:13 +0800 Subject: [PATCH 1/9] refactor: certdeploy module --- cmd/intercmd.go | 2 +- internal/certapply/client_certifier.go | 6 +- internal/certdeploy/client.go | 25 +++++++ internal/certdeploy/client_deployer.go | 49 ++++++++++++ internal/deployer/deployer.go | 75 ------------------- internal/deployer/providers.go | 25 ------- internal/workflow/engine/executor_bizapply.go | 4 +- .../workflow/engine/executor_bizdeploy.go | 35 +++++---- 8 files changed, 102 insertions(+), 119 deletions(-) create mode 100644 internal/certdeploy/client.go create mode 100644 internal/certdeploy/client_deployer.go delete mode 100644 internal/deployer/deployer.go delete mode 100644 internal/deployer/providers.go diff --git a/cmd/intercmd.go b/cmd/intercmd.go index 1fc790b49..49268217b 100644 --- a/cmd/intercmd.go +++ b/cmd/intercmd.go @@ -69,7 +69,7 @@ func internalCertApplyCommand(app core.App) *cobra.Command { return nil, fmt.Errorf("failed to initialize acme client: %w", err) } - resp, err := client.ObtainCertificateWithContext(ctx, params.Request) + resp, err := client.ObtainCertificate(ctx, params.Request) if err != nil { return nil, fmt.Errorf("failed to obtain certificate: %w", err) } diff --git a/internal/certapply/client_certifier.go b/internal/certapply/client_certifier.go index 30c1574e7..cf144bd77 100644 --- a/internal/certapply/client_certifier.go +++ b/internal/certapply/client_certifier.go @@ -63,7 +63,7 @@ type ObtainCertificateResponse struct { ARIReplaced bool } -func (c *ACMEClient) ObtainCertificateWithContext(ctx context.Context, request *ObtainCertificateRequest) (*ObtainCertificateResponse, error) { +func (c *ACMEClient) ObtainCertificate(ctx context.Context, request *ObtainCertificateRequest) (*ObtainCertificateResponse, error) { type result struct { res *ObtainCertificateResponse err error @@ -72,7 +72,7 @@ func (c *ACMEClient) ObtainCertificateWithContext(ctx context.Context, request * done := make(chan result, 1) go func() { - res, err := c.ObtainCertificate(request) + res, err := c.sendObtainCertificateRequest(request) done <- result{res, err} }() @@ -84,7 +84,7 @@ func (c *ACMEClient) ObtainCertificateWithContext(ctx context.Context, request * } } -func (c *ACMEClient) ObtainCertificate(request *ObtainCertificateRequest) (*ObtainCertificateResponse, error) { +func (c *ACMEClient) sendObtainCertificateRequest(request *ObtainCertificateRequest) (*ObtainCertificateResponse, error) { if request == nil { return nil, errors.New("the request is nil") } diff --git a/internal/certdeploy/client.go b/internal/certdeploy/client.go new file mode 100644 index 000000000..956b4b37e --- /dev/null +++ b/internal/certdeploy/client.go @@ -0,0 +1,25 @@ +package certdeploy + +import ( + "log/slog" +) + +type Client struct { + logger *slog.Logger +} + +type ClientConfigure func(*Client) + +func NewClient(configures ...ClientConfigure) *Client { + client := &Client{} + for _, configure := range configures { + configure(client) + } + return client +} + +func WithLogger(logger *slog.Logger) ClientConfigure { + return func(c *Client) { + c.logger = logger + } +} diff --git a/internal/certdeploy/client_deployer.go b/internal/certdeploy/client_deployer.go new file mode 100644 index 000000000..d42952923 --- /dev/null +++ b/internal/certdeploy/client_deployer.go @@ -0,0 +1,49 @@ +package certdeploy + +import ( + "context" + "errors" + "fmt" + + "github.com/certimate-go/certimate/internal/certdeploy/deployers" + "github.com/certimate-go/certimate/internal/domain" +) + +type DeployCertificateRequest struct { + // 提供商相关 + Provider string + ProviderAccessConfig map[string]any + ProviderExtendedConfig map[string]any + + // 证书相关 + Certificate string + PrivateKey string +} + +type DeployCertificateResponse struct{} + +func (c *Client) DeployCertificate(ctx context.Context, request *DeployCertificateRequest) (*DeployCertificateResponse, error) { + if request == nil { + return nil, errors.New("the request is nil") + } + + providerFactory, err := deployers.Registries.Get(domain.DeploymentProviderType(request.Provider)) + if err != nil { + return nil, err + } + + provider, err := providerFactory(&deployers.ProviderFactoryOptions{ + ProviderAccessConfig: request.ProviderAccessConfig, + ProviderExtendedConfig: request.ProviderExtendedConfig, + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize deployment provider '%s': %w", request.Provider, err) + } + + provider.SetLogger(c.logger) + if _, err := provider.Deploy(ctx, request.Certificate, request.PrivateKey); err != nil { + return nil, err + } + + return &DeployCertificateResponse{}, nil +} diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go deleted file mode 100644 index 13cdbef84..000000000 --- a/internal/deployer/deployer.go +++ /dev/null @@ -1,75 +0,0 @@ -package deployer - -import ( - "context" - "fmt" - "log/slog" - - "github.com/certimate-go/certimate/internal/domain" - "github.com/certimate-go/certimate/internal/repository" - "github.com/certimate-go/certimate/pkg/core" -) - -type Deployer interface { - Deploy(ctx context.Context) error -} - -type DeployerWithWorkflowNodeConfig struct { - Node *domain.WorkflowNode - Logger *slog.Logger - CertificatePEM string - PrivateKeyPEM string -} - -// TODO: refactor -func NewWithWorkflowNode(config DeployerWithWorkflowNodeConfig) (Deployer, error) { - if config.Node == nil { - return nil, fmt.Errorf("the node is nil") - } - if config.Node.Type != domain.WorkflowNodeTypeBizDeploy { - return nil, fmt.Errorf("the node type is '%s', expected '%s'", string(config.Node.Type), string(domain.WorkflowNodeTypeBizDeploy)) - } - - nodeCfg := config.Node.Data.Config.AsBizDeploy() - options := &deployerProviderOptions{ - Provider: domain.DeploymentProviderType(nodeCfg.Provider), - ProviderAccessConfig: make(map[string]any), - ProviderExtendedConfig: nodeCfg.ProviderConfig, - } - - accessRepo := repository.NewAccessRepository() - if nodeCfg.ProviderAccessId != "" { - access, err := accessRepo.GetById(context.Background(), nodeCfg.ProviderAccessId) - if err != nil { - return nil, fmt.Errorf("failed to get access #%s record: %w", nodeCfg.ProviderAccessId, err) - } else { - options.ProviderAccessConfig = access.Config - } - } - - deployer, err := createSSLDeployerProvider(options) - if err != nil { - return nil, err - } else { - deployer.SetLogger(config.Logger) - } - - return &deployerImpl{ - provider: deployer, - certPEM: config.CertificatePEM, - privkeyPEM: config.PrivateKeyPEM, - }, nil -} - -type deployerImpl struct { - provider core.SSLDeployer - certPEM string - privkeyPEM string -} - -var _ Deployer = (*deployerImpl)(nil) - -func (d *deployerImpl) Deploy(ctx context.Context) error { - _, err := d.provider.Deploy(ctx, d.certPEM, d.privkeyPEM) - return err -} diff --git a/internal/deployer/providers.go b/internal/deployer/providers.go deleted file mode 100644 index 18663d28e..000000000 --- a/internal/deployer/providers.go +++ /dev/null @@ -1,25 +0,0 @@ -package deployer - -import ( - "github.com/certimate-go/certimate/internal/certdeploy/deployers" - "github.com/certimate-go/certimate/internal/domain" - "github.com/certimate-go/certimate/pkg/core" -) - -type deployerProviderOptions struct { - Provider domain.DeploymentProviderType - ProviderAccessConfig map[string]any - ProviderExtendedConfig map[string]any -} - -func createSSLDeployerProvider(options *deployerProviderOptions) (core.SSLDeployer, error) { - provider, err := deployers.Registries.Get(options.Provider) - if err != nil { - return nil, err - } - - return provider(&deployers.ProviderFactoryOptions{ - ProviderAccessConfig: options.ProviderAccessConfig, - ProviderExtendedConfig: options.ProviderExtendedConfig, - }) -} diff --git a/internal/workflow/engine/executor_bizapply.go b/internal/workflow/engine/executor_bizapply.go index a69d09ed8..2d6c1921b 100644 --- a/internal/workflow/engine/executor_bizapply.go +++ b/internal/workflow/engine/executor_bizapply.go @@ -343,8 +343,8 @@ func (ne *bizApplyNodeExecutor) executeObtain(execCtx *NodeExecutionContext, nod return nil, err } - // 申请证书 - obtainResp, err := legoClient.ObtainCertificateWithContext(execCtx.ctx, obtainReq) + // 执行申请证书请求 + obtainResp, err := legoClient.ObtainCertificate(execCtx.ctx, obtainReq) if err != nil { ne.logger.Warn("failed to obtain certificate") return nil, err diff --git a/internal/workflow/engine/executor_bizdeploy.go b/internal/workflow/engine/executor_bizdeploy.go index 73dc965be..9f8b98b85 100644 --- a/internal/workflow/engine/executor_bizdeploy.go +++ b/internal/workflow/engine/executor_bizdeploy.go @@ -6,7 +6,7 @@ import ( "maps" "strings" - "github.com/certimate-go/certimate/internal/deployer" + "github.com/certimate-go/certimate/internal/certdeploy" "github.com/certimate-go/certimate/internal/domain" "github.com/certimate-go/certimate/internal/repository" ) @@ -18,6 +18,7 @@ import ( type bizDeployNodeExecutor struct { nodeExecutor + accessRepo accessRepository certificateRepo certificateRepository wfoutputRepo workflowOutputRepository } @@ -66,21 +67,28 @@ func (ne *bizDeployNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx } } - // 初始化部署器 - // TODO: 解耦 - deployer, err := deployer.NewWithWorkflowNode(deployer.DeployerWithWorkflowNodeConfig{ - Node: execCtx.Node, - Logger: ne.logger, - CertificatePEM: inputCertificate.Certificate, - PrivateKeyPEM: inputCertificate.PrivateKey, - }) - if err != nil { - ne.logger.Warn("failed to create deployer provider") - return execRes, err + // 读取部署提供商授权 + providerAccessConfig := make(map[string]any) + if nodeCfg.ProviderAccessId != "" { + if access, err := ne.accessRepo.GetById(execCtx.ctx, nodeCfg.ProviderAccessId); err != nil { + return nil, fmt.Errorf("failed to get access #%s record: %w", nodeCfg.ProviderAccessId, err) + } else { + providerAccessConfig = access.Config + } } + // 初始化部署器 + deployClient := certdeploy.NewClient(certdeploy.WithLogger(ne.logger)) + // 部署证书 - if err := deployer.Deploy(execCtx.ctx); err != nil { + deployReq := &certdeploy.DeployCertificateRequest{ + Provider: nodeCfg.Provider, + ProviderAccessConfig: providerAccessConfig, + ProviderExtendedConfig: nodeCfg.ProviderConfig, + Certificate: inputCertificate.Certificate, + PrivateKey: inputCertificate.PrivateKey, + } + if _, err := deployClient.DeployCertificate(execCtx.ctx, deployReq); err != nil { ne.logger.Warn("failed to deploy certificate") return execRes, err } @@ -127,6 +135,7 @@ func (ne *bizDeployNodeExecutor) checkCanSkip(execCtx *NodeExecutionContext, las func newBizDeployNodeExecutor() NodeExecutor { return &bizDeployNodeExecutor{ nodeExecutor: nodeExecutor{logger: slog.Default()}, + accessRepo: repository.NewAccessRepository(), certificateRepo: repository.NewCertificateRepository(), wfoutputRepo: repository.NewWorkflowOutputRepository(), } From 34dd521efdff88142d0fa6174046bd176410312f Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Thu, 4 Sep 2025 09:52:59 +0800 Subject: [PATCH 2/9] refactor: notify module --- internal/notify/client.go | 25 +++++++ internal/notify/client_notifier.go | 49 ++++++++++++ internal/notify/notifier.go | 75 ------------------- internal/notify/providers.go | 25 ------- internal/notify/service.go | 4 +- .../workflow/engine/executor_biznotify.go | 32 +++++--- 6 files changed, 97 insertions(+), 113 deletions(-) create mode 100644 internal/notify/client.go create mode 100644 internal/notify/client_notifier.go delete mode 100644 internal/notify/notifier.go delete mode 100644 internal/notify/providers.go diff --git a/internal/notify/client.go b/internal/notify/client.go new file mode 100644 index 000000000..7b5de5c8d --- /dev/null +++ b/internal/notify/client.go @@ -0,0 +1,25 @@ +package notify + +import ( + "log/slog" +) + +type Client struct { + logger *slog.Logger +} + +type ClientConfigure func(*Client) + +func NewClient(configures ...ClientConfigure) *Client { + client := &Client{} + for _, configure := range configures { + configure(client) + } + return client +} + +func WithLogger(logger *slog.Logger) ClientConfigure { + return func(c *Client) { + c.logger = logger + } +} diff --git a/internal/notify/client_notifier.go b/internal/notify/client_notifier.go new file mode 100644 index 000000000..5611245b7 --- /dev/null +++ b/internal/notify/client_notifier.go @@ -0,0 +1,49 @@ +package notify + +import ( + "context" + "errors" + "fmt" + + "github.com/certimate-go/certimate/internal/domain" + "github.com/certimate-go/certimate/internal/notify/notifiers" +) + +type SendNotificationRequest struct { + // 提供商相关 + Provider string + ProviderAccessConfig map[string]any + ProviderExtendedConfig map[string]any + + // 通知相关 + Subject string + Message string +} + +type SendNotificationResponse struct{} + +func (c *Client) SendNotification(ctx context.Context, request *SendNotificationRequest) (*SendNotificationResponse, error) { + if request == nil { + return nil, errors.New("the request is nil") + } + + providerFactory, err := notifiers.Registries.Get(domain.NotificationProviderType(request.Provider)) + if err != nil { + return nil, err + } + + provider, err := providerFactory(¬ifiers.ProviderFactoryOptions{ + ProviderAccessConfig: request.ProviderAccessConfig, + ProviderExtendedConfig: request.ProviderExtendedConfig, + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize notification provider '%s': %w", request.Provider, err) + } + + provider.SetLogger(c.logger) + if _, err := provider.Notify(ctx, request.Subject, request.Message); err != nil { + return nil, err + } + + return &SendNotificationResponse{}, nil +} diff --git a/internal/notify/notifier.go b/internal/notify/notifier.go deleted file mode 100644 index 410881f14..000000000 --- a/internal/notify/notifier.go +++ /dev/null @@ -1,75 +0,0 @@ -package notify - -import ( - "context" - "fmt" - "log/slog" - - "github.com/certimate-go/certimate/internal/domain" - "github.com/certimate-go/certimate/internal/repository" - "github.com/certimate-go/certimate/pkg/core" -) - -type Notifier interface { - Notify(ctx context.Context) error -} - -type NotifierWithWorkflowNodeConfig struct { - Node *domain.WorkflowNode - Logger *slog.Logger - Subject string - Message string -} - -// TODO: refactor -func NewWithWorkflowNode(config NotifierWithWorkflowNodeConfig) (Notifier, error) { - if config.Node == nil { - return nil, fmt.Errorf("the node is nil") - } - if config.Node.Type != domain.WorkflowNodeTypeBizNotify { - return nil, fmt.Errorf("the node type is '%s', expected '%s'", string(config.Node.Type), string(domain.WorkflowNodeTypeBizNotify)) - } - - nodeCfg := config.Node.Data.Config.AsBizNotify() - options := ¬ifierProviderOptions{ - Provider: domain.NotificationProviderType(nodeCfg.Provider), - ProviderAccessConfig: make(map[string]any), - ProviderExtendedConfig: nodeCfg.ProviderConfig, - } - - accessRepo := repository.NewAccessRepository() - if nodeCfg.ProviderAccessId != "" { - access, err := accessRepo.GetById(context.Background(), nodeCfg.ProviderAccessId) - if err != nil { - return nil, fmt.Errorf("failed to get access #%s record: %w", nodeCfg.ProviderAccessId, err) - } else { - options.ProviderAccessConfig = access.Config - } - } - - notifier, err := createNotifierProvider(options) - if err != nil { - return nil, err - } else { - notifier.SetLogger(config.Logger) - } - - return ¬ifierImpl{ - provider: notifier, - subject: config.Subject, - message: config.Message, - }, nil -} - -type notifierImpl struct { - provider core.Notifier - subject string - message string -} - -var _ Notifier = (*notifierImpl)(nil) - -func (n *notifierImpl) Notify(ctx context.Context) error { - _, err := n.provider.Notify(ctx, n.subject, n.message) - return err -} diff --git a/internal/notify/providers.go b/internal/notify/providers.go deleted file mode 100644 index f2a6bbf01..000000000 --- a/internal/notify/providers.go +++ /dev/null @@ -1,25 +0,0 @@ -package notify - -import ( - "github.com/certimate-go/certimate/internal/domain" - "github.com/certimate-go/certimate/internal/notify/notifiers" - "github.com/certimate-go/certimate/pkg/core" -) - -type notifierProviderOptions struct { - Provider domain.NotificationProviderType - ProviderAccessConfig map[string]any - ProviderExtendedConfig map[string]any -} - -func createNotifierProvider(options *notifierProviderOptions) (core.Notifier, error) { - provider, err := notifiers.Registries.Get(options.Provider) - if err != nil { - return nil, err - } - - return provider(¬ifiers.ProviderFactoryOptions{ - ProviderAccessConfig: options.ProviderAccessConfig, - ProviderExtendedConfig: options.ProviderExtendedConfig, - }) -} diff --git a/internal/notify/service.go b/internal/notify/service.go index b689d9db0..286bab07f 100644 --- a/internal/notify/service.go +++ b/internal/notify/service.go @@ -8,8 +8,8 @@ import ( ) const ( - notifyTestTitle = "[Certimate] Notification Test" - notifyTestBody = "Welcome to use Certimate!" + notifyTestSubject = "[Certimate] Notification Testing" + notifyTestMessage = "Welcome to use Certimate!" ) type NotifyService struct{} diff --git a/internal/workflow/engine/executor_biznotify.go b/internal/workflow/engine/executor_biznotify.go index c15c74ce5..e1410faeb 100644 --- a/internal/workflow/engine/executor_biznotify.go +++ b/internal/workflow/engine/executor_biznotify.go @@ -11,6 +11,7 @@ import ( type bizNotifyNodeExecutor struct { nodeExecutor + accessRepo accessRepository settingsRepo settingsRepository } @@ -26,20 +27,28 @@ func (ne *bizNotifyNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx return execRes, nil } - // 初始化通知器 - deployer, err := notify.NewWithWorkflowNode(notify.NotifierWithWorkflowNodeConfig{ - Node: execCtx.Node, - Logger: ne.logger, - Subject: nodeCfg.Subject, - Message: nodeCfg.Message, - }) - if err != nil { - ne.logger.Warn("failed to create notifier provider") - return execRes, err + // 读取部署提供商授权 + providerAccessConfig := make(map[string]any) + if nodeCfg.ProviderAccessId != "" { + if access, err := ne.accessRepo.GetById(execCtx.ctx, nodeCfg.ProviderAccessId); err != nil { + return nil, fmt.Errorf("failed to get access #%s record: %w", nodeCfg.ProviderAccessId, err) + } else { + providerAccessConfig = access.Config + } } + // 初始化通知器 + notifyClient := notify.NewClient(notify.WithLogger(ne.logger)) + // 推送通知 - if err := deployer.Notify(execCtx.ctx); err != nil { + notifyReq := ¬ify.SendNotificationRequest{ + Provider: nodeCfg.Provider, + ProviderAccessConfig: providerAccessConfig, + ProviderExtendedConfig: nodeCfg.ProviderConfig, + Subject: nodeCfg.Subject, + Message: nodeCfg.Message, + } + if _, err := notifyClient.SendNotification(execCtx.ctx, notifyReq); err != nil { ne.logger.Warn("failed to send notification") return execRes, err } @@ -69,6 +78,7 @@ func (ne *bizNotifyNodeExecutor) checkCanSkip(execCtx *NodeExecutionContext) (_s func newBizNotifyNodeExecutor() NodeExecutor { return &bizNotifyNodeExecutor{ nodeExecutor: nodeExecutor{logger: slog.Default()}, + accessRepo: repository.NewAccessRepository(), settingsRepo: repository.NewSettingsRepository(), } } From a5da1e47af80a267f8c2ceb0b6ccc846839579e7 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Thu, 4 Sep 2025 11:27:40 +0800 Subject: [PATCH 3/9] feat(provider): new acme http-01 provider: ssh --- internal/certapply/applicators/sp_ssh.go | 51 +++ internal/certdeploy/deployers/sp_ssh.go | 20 +- internal/domain/provider.go | 1 + .../acme-http01/providers/local/local.go | 1 + .../acme-http01/providers/ssh/ssh.go | 292 ++++++++++++++++++ pkg/core/ssl-deployer/providers/ssh/ssh.go | 114 +------ pkg/utils/ssh/io.go | 134 ++++++++ 7 files changed, 505 insertions(+), 108 deletions(-) create mode 100644 internal/certapply/applicators/sp_ssh.go create mode 100644 pkg/core/ssl-applicator/acme-http01/providers/ssh/ssh.go create mode 100644 pkg/utils/ssh/io.go diff --git a/internal/certapply/applicators/sp_ssh.go b/internal/certapply/applicators/sp_ssh.go new file mode 100644 index 000000000..d4d6c7fc3 --- /dev/null +++ b/internal/certapply/applicators/sp_ssh.go @@ -0,0 +1,51 @@ +package applicators + +import ( + "fmt" + + "github.com/go-acme/lego/v4/challenge" + + "github.com/certimate-go/certimate/internal/domain" + "github.com/certimate-go/certimate/pkg/core/ssl-applicator/acme-http01/providers/ssh" + xmaps "github.com/certimate-go/certimate/pkg/utils/maps" +) + +func init() { + if err := ACMEHttp01Registries.Register(domain.ACMEHttp01ProviderTypeSSH, func(options *ProviderFactoryOptions) (challenge.Provider, error) { + credentials := domain.AccessConfigForSSH{} + if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil { + return nil, fmt.Errorf("failed to populate provider access config: %w", err) + } + + jumpServers := make([]ssh.ServerConfig, len(credentials.JumpServers)) + for i, jumpServer := range credentials.JumpServers { + jumpServers[i] = ssh.ServerConfig{ + SshHost: jumpServer.Host, + SshPort: jumpServer.Port, + SshAuthMethod: jumpServer.AuthMethod, + SshUsername: jumpServer.Username, + SshPassword: jumpServer.Password, + SshKey: jumpServer.Key, + SshKeyPassphrase: jumpServer.KeyPassphrase, + } + } + + provider, err := ssh.NewChallengeProvider(&ssh.ChallengeProviderConfig{ + ServerConfig: ssh.ServerConfig{ + SshHost: credentials.Host, + SshPort: credentials.Port, + SshAuthMethod: credentials.AuthMethod, + SshUsername: credentials.Username, + SshPassword: credentials.Password, + SshKey: credentials.Key, + SshKeyPassphrase: credentials.KeyPassphrase, + }, + JumpServers: jumpServers, + UseSCP: xmaps.GetBool(options.ProviderExtendedConfig, "useSCP"), + WebRootPath: xmaps.GetString(options.ProviderExtendedConfig, "webRootPath"), + }) + return provider, err + }); err != nil { + panic(err) + } +} diff --git a/internal/certdeploy/deployers/sp_ssh.go b/internal/certdeploy/deployers/sp_ssh.go index ee81c0f17..e939e6f16 100644 --- a/internal/certdeploy/deployers/sp_ssh.go +++ b/internal/certdeploy/deployers/sp_ssh.go @@ -16,9 +16,9 @@ func init() { return nil, fmt.Errorf("failed to populate provider access config: %w", err) } - jumpServers := make([]ssh.JumpServerConfig, len(credentials.JumpServers)) + jumpServers := make([]ssh.ServerConfig, len(credentials.JumpServers)) for i, jumpServer := range credentials.JumpServers { - jumpServers[i] = ssh.JumpServerConfig{ + jumpServers[i] = ssh.ServerConfig{ SshHost: jumpServer.Host, SshPort: jumpServer.Port, SshAuthMethod: jumpServer.AuthMethod, @@ -30,13 +30,15 @@ func init() { } provider, err := ssh.NewSSLDeployerProvider(&ssh.SSLDeployerProviderConfig{ - SshHost: credentials.Host, - SshPort: credentials.Port, - SshAuthMethod: credentials.AuthMethod, - SshUsername: credentials.Username, - SshPassword: credentials.Password, - SshKey: credentials.Key, - SshKeyPassphrase: credentials.KeyPassphrase, + ServerConfig: ssh.ServerConfig{ + SshHost: credentials.Host, + SshPort: credentials.Port, + SshAuthMethod: credentials.AuthMethod, + SshUsername: credentials.Username, + SshPassword: credentials.Password, + SshKey: credentials.Key, + SshKeyPassphrase: credentials.KeyPassphrase, + }, JumpServers: jumpServers, UseSCP: xmaps.GetBool(options.ProviderExtendedConfig, "useSCP"), PreCommand: xmaps.GetString(options.ProviderExtendedConfig, "preCommand"), diff --git a/internal/domain/provider.go b/internal/domain/provider.go index c7f32c2c9..e35919a7c 100644 --- a/internal/domain/provider.go +++ b/internal/domain/provider.go @@ -185,6 +185,7 @@ NOTICE: If you add new constant, please keep ASCII order. */ const ( ACMEHttp01ProviderTypeLocal = ACMEHttp01ProviderType(AccessProviderTypeLocal) + ACMEHttp01ProviderTypeSSH = ACMEHttp01ProviderType(AccessProviderTypeSSH) ) type DeploymentProviderType string diff --git a/pkg/core/ssl-applicator/acme-http01/providers/local/local.go b/pkg/core/ssl-applicator/acme-http01/providers/local/local.go index 53a98167f..50fa2d57b 100644 --- a/pkg/core/ssl-applicator/acme-http01/providers/local/local.go +++ b/pkg/core/ssl-applicator/acme-http01/providers/local/local.go @@ -9,6 +9,7 @@ import ( ) type ChallengeProviderConfig struct { + // 网站根目录路径。 WebRootPath string `json:"webRootPath"` } diff --git a/pkg/core/ssl-applicator/acme-http01/providers/ssh/ssh.go b/pkg/core/ssl-applicator/acme-http01/providers/ssh/ssh.go new file mode 100644 index 000000000..9863d6aa7 --- /dev/null +++ b/pkg/core/ssl-applicator/acme-http01/providers/ssh/ssh.go @@ -0,0 +1,292 @@ +package ssh + +import ( + "errors" + "fmt" + "net" + "path/filepath" + "strconv" + "strings" + + "github.com/go-acme/lego/v4/challenge/http01" + "golang.org/x/crypto/ssh" + + "github.com/certimate-go/certimate/pkg/core" + xssh "github.com/certimate-go/certimate/pkg/utils/ssh" +) + +type ServerConfig struct { + // SSH 主机。 + // 零值时默认值 "localhost"。 + SshHost string `json:"sshHost,omitempty"` + // SSH 端口。 + // 零值时默认值 22。 + SshPort int32 `json:"sshPort,omitempty"` + // SSH 认证方式。 + // 可取值 "none"、"password"、"key"。 + // 零值时根据有无密码或私钥字段决定。 + SshAuthMethod string `json:"sshAuthMethod,omitempty"` + // SSH 登录用户名。 + // 零值时默认值 "root"。 + SshUsername string `json:"sshUsername,omitempty"` + // SSH 登录密码。 + SshPassword string `json:"sshPassword,omitempty"` + // SSH 登录私钥。 + SshKey string `json:"sshKey,omitempty"` + // SSH 登录私钥口令。 + SshKeyPassphrase string `json:"sshKeyPassphrase,omitempty"` +} + +type ChallengeProviderConfig struct { + ServerConfig + + // 跳板机配置数组。 + JumpServers []ServerConfig `json:"jumpServers,omitempty"` + // 是否回退使用 SCP。 + UseSCP bool `json:"useSCP,omitempty"` + // 网站根目录路径。 + WebRootPath string `json:"webRootPath"` +} + +func NewChallengeProvider(config *ChallengeProviderConfig) (core.ACMEChallenger, error) { + if config == nil { + return nil, errors.New("the configuration of the acme challenge provider is nil") + } + + provider := &provider{config: config} + return provider, nil +} + +type provider struct { + config *ChallengeProviderConfig +} + +func (p *provider) Present(domain, token, keyAuth string) error { + var err error + + // 创建 TCP 链接 + var targetConn net.Conn + if len(p.config.JumpServers) > 0 { + var jumpClient *ssh.Client + for i, jumpServerConf := range p.config.JumpServers { + var jumpConn net.Conn + // 第一个连接是主机发起,后续通过跳板机发起 + if jumpClient == nil { + jumpConn, err = net.Dial("tcp", net.JoinHostPort(jumpServerConf.SshHost, strconv.Itoa(int(jumpServerConf.SshPort)))) + } else { + jumpConn, err = jumpClient.Dial("tcp", net.JoinHostPort(jumpServerConf.SshHost, strconv.Itoa(int(jumpServerConf.SshPort)))) + } + if err != nil { + return fmt.Errorf("failed to connect to jump server [%d]: %w", i+1, err) + } + defer jumpConn.Close() + + newClient, err := p.createSshClient( + jumpConn, + jumpServerConf.SshHost, + jumpServerConf.SshPort, + jumpServerConf.SshAuthMethod, + jumpServerConf.SshUsername, + jumpServerConf.SshPassword, + jumpServerConf.SshKey, + jumpServerConf.SshKeyPassphrase, + ) + if err != nil { + return fmt.Errorf("failed to create jump server ssh client[%d]: %w", i+1, err) + } + defer newClient.Close() + + jumpClient = newClient + } + + // 通过跳板机发起 TCP 连接到目标服务器 + targetConn, err = jumpClient.Dial("tcp", net.JoinHostPort(p.config.SshHost, strconv.Itoa(int(p.config.SshPort)))) + if err != nil { + return fmt.Errorf("failed to connect to target server: %w", err) + } + } else { + // 直接发起 TCP 连接到目标服务器 + targetConn, err = net.Dial("tcp", net.JoinHostPort(p.config.SshHost, strconv.Itoa(int(p.config.SshPort)))) + if err != nil { + return fmt.Errorf("failed to connect to target server: %w", err) + } + } + defer targetConn.Close() + + // 创建 SSH 客户端 + client, err := p.createSshClient( + targetConn, + p.config.SshHost, + p.config.SshPort, + p.config.SshAuthMethod, + p.config.SshUsername, + p.config.SshPassword, + p.config.SshKey, + p.config.SshKeyPassphrase, + ) + if err != nil { + return fmt.Errorf("failed to create ssh client: %w", err) + } + defer client.Close() + + // 写入质询文件 + challengeFilePath := filepath.Join(p.config.WebRootPath, http01.ChallengePath(token)) + if err := xssh.WriteRemoteString(client, challengeFilePath, keyAuth, p.config.UseSCP); err != nil { + return fmt.Errorf("failed to write file in webroot for HTTP challenge: %w", err) + } + + return nil +} + +func (p *provider) CleanUp(domain, token, keyAuth string) error { + var err error + + // 创建 TCP 链接 + var targetConn net.Conn + if len(p.config.JumpServers) > 0 { + var jumpClient *ssh.Client + for i, jumpServerConf := range p.config.JumpServers { + var jumpConn net.Conn + // 第一个连接是主机发起,后续通过跳板机发起 + if jumpClient == nil { + jumpConn, err = net.Dial("tcp", net.JoinHostPort(jumpServerConf.SshHost, strconv.Itoa(int(jumpServerConf.SshPort)))) + } else { + jumpConn, err = jumpClient.Dial("tcp", net.JoinHostPort(jumpServerConf.SshHost, strconv.Itoa(int(jumpServerConf.SshPort)))) + } + if err != nil { + return fmt.Errorf("failed to connect to jump server [%d]: %w", i+1, err) + } + defer jumpConn.Close() + + newClient, err := p.createSshClient( + jumpConn, + jumpServerConf.SshHost, + jumpServerConf.SshPort, + jumpServerConf.SshAuthMethod, + jumpServerConf.SshUsername, + jumpServerConf.SshPassword, + jumpServerConf.SshKey, + jumpServerConf.SshKeyPassphrase, + ) + if err != nil { + return fmt.Errorf("failed to create jump server ssh client[%d]: %w", i+1, err) + } + defer newClient.Close() + + jumpClient = newClient + } + + // 通过跳板机发起 TCP 连接到目标服务器 + targetConn, err = jumpClient.Dial("tcp", net.JoinHostPort(p.config.SshHost, strconv.Itoa(int(p.config.SshPort)))) + if err != nil { + return fmt.Errorf("failed to connect to target server: %w", err) + } + } else { + // 直接发起 TCP 连接到目标服务器 + targetConn, err = net.Dial("tcp", net.JoinHostPort(p.config.SshHost, strconv.Itoa(int(p.config.SshPort)))) + if err != nil { + return fmt.Errorf("failed to connect to target server: %w", err) + } + } + defer targetConn.Close() + + // 创建 SSH 客户端 + client, err := p.createSshClient( + targetConn, + p.config.SshHost, + p.config.SshPort, + p.config.SshAuthMethod, + p.config.SshUsername, + p.config.SshPassword, + p.config.SshKey, + p.config.SshKeyPassphrase, + ) + if err != nil { + return fmt.Errorf("failed to create ssh client: %w", err) + } + defer client.Close() + + // 删除质询文件 + challengeFilePath := filepath.Join(p.config.WebRootPath, http01.ChallengePath(token)) + xssh.RemoveRemote(client, challengeFilePath, p.config.UseSCP) + + return nil +} + +func (p *provider) createSshClient(conn net.Conn, host string, port int32, authMethod string, username, password, key, keyPassphrase string) (*ssh.Client, error) { + if host == "" { + host = "localhost" + } + + if port == 0 { + port = 22 + } + + if username == "" { + username = "root" + } + + const AUTH_METHOD_NONE = "none" + const AUTH_METHOD_PASSWORD = "password" + const AUTH_METHOD_KEY = "key" + if authMethod == "" { + if key != "" { + authMethod = AUTH_METHOD_KEY + } else if password != "" { + authMethod = AUTH_METHOD_PASSWORD + } else { + authMethod = AUTH_METHOD_NONE + } + } + + authentications := make([]ssh.AuthMethod, 0) + switch authMethod { + case AUTH_METHOD_NONE: + { + } + + case AUTH_METHOD_PASSWORD: + { + authentications = append(authentications, ssh.Password(password)) + authentications = append(authentications, ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) ([]string, error) { + if len(questions) == 1 { + return []string{password}, nil + } + return nil, fmt.Errorf("unexpected keyboard interactive question [%s]", strings.Join(questions, ", ")) + })) + } + + case AUTH_METHOD_KEY: + { + var signer ssh.Signer + var err error + + if keyPassphrase != "" { + signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(key), []byte(keyPassphrase)) + } else { + signer, err = ssh.ParsePrivateKey([]byte(key)) + } + + if err != nil { + return nil, err + } + + authentications = append(authentications, ssh.PublicKeys(signer)) + } + + default: + return nil, fmt.Errorf("unsupported auth method '%s'", authMethod) + } + + addr := net.JoinHostPort(host, strconv.Itoa(int(port))) + sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, &ssh.ClientConfig{ + User: username, + Auth: authentications, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + }) + if err != nil { + return nil, err + } + + return ssh.NewClient(sshConn, chans, reqs), nil +} diff --git a/pkg/core/ssl-deployer/providers/ssh/ssh.go b/pkg/core/ssl-deployer/providers/ssh/ssh.go index 7cbe7c8f3..666cc6c79 100644 --- a/pkg/core/ssl-deployer/providers/ssh/ssh.go +++ b/pkg/core/ssl-deployer/providers/ssh/ssh.go @@ -7,20 +7,17 @@ import ( "fmt" "log/slog" "net" - "os" - "path/filepath" "strconv" "strings" - "github.com/pkg/sftp" - "github.com/povsister/scp" "golang.org/x/crypto/ssh" "github.com/certimate-go/certimate/pkg/core" xcert "github.com/certimate-go/certimate/pkg/utils/cert" + xssh "github.com/certimate-go/certimate/pkg/utils/ssh" ) -type JumpServerConfig struct { +type ServerConfig struct { // SSH 主机。 // 零值时默认值 "localhost"。 SshHost string `json:"sshHost,omitempty"` @@ -43,27 +40,10 @@ type JumpServerConfig struct { } type SSLDeployerProviderConfig struct { - // SSH 主机。 - // 零值时默认值 "localhost"。 - SshHost string `json:"sshHost,omitempty"` - // SSH 端口。 - // 零值时默认值 22。 - SshPort int32 `json:"sshPort,omitempty"` - // SSH 认证方式。 - // 可取值 "none"、"password" 或 "key"。 - // 零值时根据有无密码或私钥字段决定。 - SshAuthMethod string `json:"sshAuthMethod,omitempty"` - // SSH 登录用户名。 - // 零值时默认值 "root"。 - SshUsername string `json:"sshUsername,omitempty"` - // SSH 登录密码。 - SshPassword string `json:"sshPassword,omitempty"` - // SSH 登录私钥。 - SshKey string `json:"sshKey,omitempty"` - // SSH 登录私钥口令。 - SshKeyPassphrase string `json:"sshKeyPassphrase,omitempty"` + ServerConfig + // 跳板机配置数组。 - JumpServers []JumpServerConfig `json:"jumpServers,omitempty"` + JumpServers []ServerConfig `json:"jumpServers,omitempty"` // 是否回退使用 SCP。 UseSCP bool `json:"useSCP,omitempty"` // 前置命令。 @@ -123,15 +103,16 @@ func (d *SSLDeployerProvider) SetLogger(logger *slog.Logger) { } func (d *SSLDeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPEM string) (*core.SSLDeployResult, error) { + var err error + // 提取服务器证书和中间证书 serverCertPEM, intermediaCertPEM, err := xcert.ExtractCertificatesFromPEM(certPEM) if err != nil { return nil, fmt.Errorf("failed to extract certs: %w", err) } + // 创建 TCP 链接 var targetConn net.Conn - - // 连接到跳板机 if len(d.config.JumpServers) > 0 { var jumpClient *ssh.Client for i, jumpServerConf := range d.config.JumpServers { @@ -182,7 +163,7 @@ func (d *SSLDeployerProvider) Deploy(ctx context.Context, certPEM string, privke } defer targetConn.Close() - // 通过已有的连接创建目标服务器 SSH 客户端 + // 创建 SSH 客户端 client, err := createSshClient( targetConn, d.config.SshHost, @@ -197,7 +178,6 @@ func (d *SSLDeployerProvider) Deploy(ctx context.Context, certPEM string, privke return nil, fmt.Errorf("failed to create ssh client: %w", err) } defer client.Close() - d.logger.Info("ssh connected") // 执行前置命令 @@ -212,26 +192,26 @@ func (d *SSLDeployerProvider) Deploy(ctx context.Context, certPEM string, privke // 上传证书和私钥文件 switch d.config.OutputFormat { case OUTPUT_FORMAT_PEM: - if err := writeFileString(client, d.config.UseSCP, d.config.OutputCertPath, certPEM); err != nil { + if err := xssh.WriteRemoteString(client, d.config.OutputCertPath, certPEM, d.config.UseSCP); err != nil { return nil, fmt.Errorf("failed to upload certificate file: %w", err) } d.logger.Info("ssl certificate file uploaded", slog.String("path", d.config.OutputCertPath)) if d.config.OutputServerCertPath != "" { - if err := writeFileString(client, d.config.UseSCP, d.config.OutputServerCertPath, serverCertPEM); err != nil { + if err := xssh.WriteRemoteString(client, d.config.OutputServerCertPath, serverCertPEM, d.config.UseSCP); err != nil { return nil, fmt.Errorf("failed to save server certificate file: %w", err) } d.logger.Info("ssl server certificate file uploaded", slog.String("path", d.config.OutputServerCertPath)) } if d.config.OutputIntermediaCertPath != "" { - if err := writeFileString(client, d.config.UseSCP, d.config.OutputIntermediaCertPath, intermediaCertPEM); err != nil { + if err := xssh.WriteRemoteString(client, d.config.OutputIntermediaCertPath, intermediaCertPEM, d.config.UseSCP); err != nil { return nil, fmt.Errorf("failed to save intermedia certificate file: %w", err) } d.logger.Info("ssl intermedia certificate file uploaded", slog.String("path", d.config.OutputIntermediaCertPath)) } - if err := writeFileString(client, d.config.UseSCP, d.config.OutputKeyPath, privkeyPEM); err != nil { + if err := xssh.WriteRemoteString(client, d.config.OutputKeyPath, privkeyPEM, d.config.UseSCP); err != nil { return nil, fmt.Errorf("failed to upload private key file: %w", err) } d.logger.Info("ssl private key file uploaded", slog.String("path", d.config.OutputKeyPath)) @@ -243,7 +223,7 @@ func (d *SSLDeployerProvider) Deploy(ctx context.Context, certPEM string, privke } d.logger.Info("ssl certificate transformed to pfx") - if err := writeFile(client, d.config.UseSCP, d.config.OutputCertPath, pfxData); err != nil { + if err := xssh.WriteRemote(client, d.config.OutputCertPath, pfxData, d.config.UseSCP); err != nil { return nil, fmt.Errorf("failed to upload certificate file: %w", err) } d.logger.Info("ssl certificate file uploaded", slog.String("path", d.config.OutputCertPath)) @@ -255,7 +235,7 @@ func (d *SSLDeployerProvider) Deploy(ctx context.Context, certPEM string, privke } d.logger.Info("ssl certificate transformed to jks") - if err := writeFile(client, d.config.UseSCP, d.config.OutputCertPath, jksData); err != nil { + if err := xssh.WriteRemote(client, d.config.OutputCertPath, jksData, d.config.UseSCP); err != nil { return nil, fmt.Errorf("failed to upload certificate file: %w", err) } d.logger.Info("ssl certificate file uploaded", slog.String("path", d.config.OutputCertPath)) @@ -372,67 +352,3 @@ func execSshCommand(sshCli *ssh.Client, command string) (string, string, error) return stdoutBuf.String(), stderrBuf.String(), nil } - -func writeFileString(sshCli *ssh.Client, useSCP bool, path string, content string) error { - if useSCP { - return writeFileStringWithSCP(sshCli, path, content) - } - - return writeFileStringWithSFTP(sshCli, path, content) -} - -func writeFile(sshCli *ssh.Client, useSCP bool, path string, data []byte) error { - if useSCP { - return writeFileWithSCP(sshCli, path, data) - } - - return writeFileWithSFTP(sshCli, path, data) -} - -func writeFileStringWithSCP(sshCli *ssh.Client, path string, content string) error { - return writeFileWithSCP(sshCli, path, []byte(content)) -} - -func writeFileWithSCP(sshCli *ssh.Client, path string, data []byte) error { - scpCli, err := scp.NewClientFromExistingSSH(sshCli, &scp.ClientOption{}) - if err != nil { - return fmt.Errorf("failed to create scp client: %w", err) - } - - reader := bytes.NewReader(data) - err = scpCli.CopyToRemote(reader, path, &scp.FileTransferOption{}) - if err != nil { - return fmt.Errorf("failed to write to remote file: %w", err) - } - - return nil -} - -func writeFileStringWithSFTP(sshCli *ssh.Client, path string, content string) error { - return writeFileWithSFTP(sshCli, path, []byte(content)) -} - -func writeFileWithSFTP(sshCli *ssh.Client, path string, data []byte) error { - sftpCli, err := sftp.NewClient(sshCli) - if err != nil { - return fmt.Errorf("failed to create sftp client: %w", err) - } - defer sftpCli.Close() - - if err := sftpCli.MkdirAll(filepath.ToSlash(filepath.Dir(path))); err != nil { - return fmt.Errorf("failed to create remote directory: %w", err) - } - - file, err := sftpCli.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC) - if err != nil { - return fmt.Errorf("failed to open remote file: %w", err) - } - defer file.Close() - - _, err = file.Write(data) - if err != nil { - return fmt.Errorf("failed to write to remote file: %w", err) - } - - return nil -} diff --git a/pkg/utils/ssh/io.go b/pkg/utils/ssh/io.go new file mode 100644 index 000000000..7261e58fa --- /dev/null +++ b/pkg/utils/ssh/io.go @@ -0,0 +1,134 @@ +package ssh + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/pkg/sftp" + "github.com/povsister/scp" + "golang.org/x/crypto/ssh" +) + +// 与 [WriteRemote] 类似,但写入的是字符串内容。 +// +// 入参: +// - sshCli: SSH 客户端。 +// - path: 文件远程路径。 +// - data: 文件数据字节数组。 +// - useSCP: 是否使用 SCP 进行传输,否则使用 SFTP。 +// +// 出参: +// - 错误。 +func WriteRemoteString(sshCli *ssh.Client, path string, content string, useSCP bool) error { + if useSCP { + return writeRemoteStringWithSCP(sshCli, path, content) + } + + return writeRemoteStringWithSFTP(sshCli, path, content) +} + +// 将数据写入指定远程路径的文件。 +// 如果目录不存在,将会递归创建目录。 +// 如果文件不存在,将会创建该文件;如果文件已存在,将会覆盖原有内容。 +// +// 入参: +// - sshCli: SSH 客户端。 +// - path: 文件远程路径。 +// - data: 文件数据字节数组。 +// - useSCP: 是否使用 SCP 进行传输,否则使用 SFTP。 +// +// 出参: +// - 错误。 +func WriteRemote(sshCli *ssh.Client, path string, data []byte, useSCP bool) error { + if useSCP { + return writeRemoteWithSCP(sshCli, path, data) + } + + return writeRemoteWithSFTP(sshCli, path, data) +} + +// 删除指定远程路径的文件。 +// +// 入参: +// - sshCli: SSH 客户端。 +// - path: 文件远程路径。 +// - useSCP: 是否使用 SCP 进行传输,否则使用 SFTP。 +// +// 出参: +// - 错误。 +func RemoveRemote(sshCli *ssh.Client, path string, useSCP bool) error { + if useSCP { + return errors.ErrUnsupported + } + + return removeRemoteWithSFTP(sshCli, path) +} + +func writeRemoteStringWithSCP(sshCli *ssh.Client, path string, content string) error { + return writeRemoteWithSCP(sshCli, path, []byte(content)) +} + +func writeRemoteStringWithSFTP(sshCli *ssh.Client, path string, content string) error { + return writeRemoteWithSFTP(sshCli, path, []byte(content)) +} + +func writeRemoteWithSCP(sshCli *ssh.Client, path string, data []byte) error { + scpCli, err := scp.NewClientFromExistingSSH(sshCli, &scp.ClientOption{}) + if err != nil { + return fmt.Errorf("failed to create scp client: %w", err) + } + + reader := bytes.NewReader(data) + err = scpCli.CopyToRemote(reader, path, &scp.FileTransferOption{}) + if err != nil { + return fmt.Errorf("failed to write to remote file: %w", err) + } + + return nil +} + +func writeRemoteWithSFTP(sshCli *ssh.Client, path string, data []byte) error { + sftpCli, err := sftp.NewClient(sshCli) + if err != nil { + return fmt.Errorf("failed to create sftp client: %w", err) + } + defer sftpCli.Close() + + if err := sftpCli.MkdirAll(filepath.ToSlash(filepath.Dir(path))); err != nil { + return fmt.Errorf("failed to create remote directory: %w", err) + } + + file, err := sftpCli.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC) + if err != nil { + return fmt.Errorf("failed to open remote file: %w", err) + } + defer file.Close() + + _, err = file.Write(data) + if err != nil { + return fmt.Errorf("failed to write to remote file: %w", err) + } + + return nil +} + +func removeRemoteWithSFTP(sshCli *ssh.Client, path string) error { + sftpCli, err := sftp.NewClient(sshCli) + if err != nil { + return fmt.Errorf("failed to create sftp client: %w", err) + } + defer sftpCli.Close() + + if err := sftpCli.MkdirAll(filepath.ToSlash(filepath.Dir(path))); err != nil { + return fmt.Errorf("failed to create remote directory: %w", err) + } + + if err := sftpCli.Remove(path); err != nil { + return fmt.Errorf("failed to remove remote file: %w", err) + } + + return nil +} From ba37eeedb53d485c290e0b6e9878630883b0d85f Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Thu, 4 Sep 2025 16:35:03 +0800 Subject: [PATCH 4/9] refactor(ui): clean code --- .../forms/AccessConfigFieldsProviderSSH.tsx | 12 +++++------- .../workflow/WorkflowGraphExportBox.tsx | 10 +++------- .../workflow/WorkflowGraphImportInputBox.tsx | 10 +++------- ui/src/pages/accesses/AccessList.tsx | 16 +++++++++++----- ui/src/pages/certificates/CertificateList.tsx | 2 +- ui/src/pages/dashboard/Dashboard.tsx | 2 +- ui/src/pages/settings/SettingsAppearance.tsx | 2 +- ui/src/pages/workflows/WorkflowList.tsx | 2 +- 8 files changed, 26 insertions(+), 30 deletions(-) diff --git a/ui/src/components/access/forms/AccessConfigFieldsProviderSSH.tsx b/ui/src/components/access/forms/AccessConfigFieldsProviderSSH.tsx index 3f32228a7..d111f38c8 100644 --- a/ui/src/components/access/forms/AccessConfigFieldsProviderSSH.tsx +++ b/ui/src/components/access/forms/AccessConfigFieldsProviderSSH.tsx @@ -50,13 +50,11 @@ const AccessConfigFormFieldsProviderSSH = ({ disabled }: { disabled?: boolean }) label={t("access.form.ssh_auth_method.label")} rules={[formRule]} > - ({ - key: s, - label: t(`access.form.ssh_auth_method.option.${s}.label`), - value: s, - }))} - /> + + {t("access.form.ssh_auth_method.option.none.label")} + {t("access.form.ssh_auth_method.option.password.label")} + {t("access.form.ssh_auth_method.option.key.label")} + diff --git a/ui/src/components/workflow/WorkflowGraphExportBox.tsx b/ui/src/components/workflow/WorkflowGraphExportBox.tsx index 06a8c74c7..8d5b18ab4 100644 --- a/ui/src/components/workflow/WorkflowGraphExportBox.tsx +++ b/ui/src/components/workflow/WorkflowGraphExportBox.tsx @@ -111,13 +111,9 @@ const WorkflowGraphExportBox = ({ className, style, data }: WorkflowGraphExportB return (
- setFormat(e.target.value)}> - - YAML - - - JSON - + setFormat(e.target.value)}> + YAML + JSON diff --git a/ui/src/components/workflow/WorkflowGraphImportInputBox.tsx b/ui/src/components/workflow/WorkflowGraphImportInputBox.tsx index 8a951cb3a..2201c6d14 100644 --- a/ui/src/components/workflow/WorkflowGraphImportInputBox.tsx +++ b/ui/src/components/workflow/WorkflowGraphImportInputBox.tsx @@ -229,13 +229,9 @@ const WorkflowGraphImportInputBox = forwardRef - handleFormatChange(e.target.value)}> - - YAML - - - JSON - + handleFormatChange(e.target.value)}> + YAML + JSON diff --git a/ui/src/pages/accesses/AccessList.tsx b/ui/src/pages/accesses/AccessList.tsx index 09e1bbfa5..2bbd8e1b1 100644 --- a/ui/src/pages/accesses/AccessList.tsx +++ b/ui/src/pages/accesses/AccessList.tsx @@ -407,7 +407,7 @@ const AccessList = () => { columns={tableColumns} dataSource={tableData} - loading={!loadedAtOnce || loading} + loading={loading} locale={{ emptyText: loading ? ( @@ -415,12 +415,18 @@ const AccessList = () => { } extra={ - + !loadedAtOnce ? ( + + ) : ( + + ) } /> ), diff --git a/ui/src/pages/certificates/CertificateList.tsx b/ui/src/pages/certificates/CertificateList.tsx index f7c50fa0b..ecd927ca7 100644 --- a/ui/src/pages/certificates/CertificateList.tsx +++ b/ui/src/pages/certificates/CertificateList.tsx @@ -417,7 +417,7 @@ const CertificateList = () => { icon={} extra={ loadError ? ( - ) : ( diff --git a/ui/src/pages/dashboard/Dashboard.tsx b/ui/src/pages/dashboard/Dashboard.tsx index 03224cac2..dece63cc1 100644 --- a/ui/src/pages/dashboard/Dashboard.tsx +++ b/ui/src/pages/dashboard/Dashboard.tsx @@ -383,7 +383,7 @@ const WorkflowRunHistoryTable = ({ className, style }: { className?: string; sty icon={} extra={ loadError ? ( - ) : ( diff --git a/ui/src/pages/settings/SettingsAppearance.tsx b/ui/src/pages/settings/SettingsAppearance.tsx index 304724fa7..893470443 100644 --- a/ui/src/pages/settings/SettingsAppearance.tsx +++ b/ui/src/pages/settings/SettingsAppearance.tsx @@ -48,7 +48,7 @@ const SettingsAppearanceTheme = ({ className, style }: { className?: string; sty
- +
{themeItems.map((item) => (
diff --git a/ui/src/pages/workflows/WorkflowList.tsx b/ui/src/pages/workflows/WorkflowList.tsx index 0deef24a1..c6d1ef7a7 100644 --- a/ui/src/pages/workflows/WorkflowList.tsx +++ b/ui/src/pages/workflows/WorkflowList.tsx @@ -495,7 +495,7 @@ const WorkflowList = () => { icon={} extra={ loadError ? ( - ) : ( From c17d27c98778a9fb70bda083f949c4e56120ed1e Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Thu, 4 Sep 2025 19:38:04 +0800 Subject: [PATCH 5/9] feat(ui): NotificationProviderPicker --- .../provider/ACMEDns01ProviderSelect.tsx | 9 +- .../provider/AccessProviderPicker.tsx | 182 ++++++++-------- .../provider/AccessProviderSelect.tsx | 34 +-- .../components/provider/CAProviderSelect.tsx | 9 +- .../provider/DeploymentProviderPicker.tsx | 184 ++++++++-------- .../provider/DeploymentProviderSelect.tsx | 9 +- .../provider/NotificationProviderPicker.tsx | 127 +++++++++++ .../provider/NotificationProviderSelect.tsx | 9 +- ui/src/components/provider/_shared.ts | 42 ++++ .../forms/BizDeployNodeConfigDrawer.tsx | 2 +- .../forms/BizDeployNodeConfigForm.tsx | 7 +- .../forms/BizNotifyNodeConfigDrawer.tsx | 7 +- .../forms/BizNotifyNodeConfigForm.tsx | 203 ++++++++---------- ui/src/i18n/locales/en/nls.provider.json | 3 +- .../i18n/locales/en/nls.workflow.nodes.json | 7 +- ui/src/i18n/locales/zh/nls.provider.json | 3 +- .../i18n/locales/zh/nls.workflow.nodes.json | 1 + 17 files changed, 489 insertions(+), 349 deletions(-) create mode 100644 ui/src/components/provider/NotificationProviderPicker.tsx create mode 100644 ui/src/components/provider/_shared.ts diff --git a/ui/src/components/provider/ACMEDns01ProviderSelect.tsx b/ui/src/components/provider/ACMEDns01ProviderSelect.tsx index 1861a847b..4d137e484 100644 --- a/ui/src/components/provider/ACMEDns01ProviderSelect.tsx +++ b/ui/src/components/provider/ACMEDns01ProviderSelect.tsx @@ -1,13 +1,12 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Avatar, Select, type SelectProps, Typography, theme } from "antd"; +import { Avatar, Select, Typography, theme } from "antd"; import { type ACMEDns01Provider, acmeDns01ProvidersMap } from "@/domain/provider"; -export interface ACMEDns01ProviderSelectProps - extends Omit { - onFilter?: (value: string, option: ACMEDns01Provider) => boolean; -} +import { type SharedSelectProps } from "./_shared"; + +export interface ACMEDns01ProviderSelectProps extends SharedSelectProps {} const ACMEDns01ProviderSelect = ({ onFilter, ...props }: ACMEDns01ProviderSelectProps) => { const { t } = useTranslation(); diff --git a/ui/src/components/provider/AccessProviderPicker.tsx b/ui/src/components/provider/AccessProviderPicker.tsx index a4d1c109f..3b7f1c5aa 100644 --- a/ui/src/components/provider/AccessProviderPicker.tsx +++ b/ui/src/components/provider/AccessProviderPicker.tsx @@ -1,37 +1,31 @@ import { useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useMount, useSize } from "ahooks"; +import { useMount } from "ahooks"; import { Avatar, Card, Empty, Input, type InputRef, Tag, Tooltip, Typography } from "antd"; import Show from "@/components/Show"; import { ACCESS_USAGES, type AccessProvider, type AccessUsageType, accessProvidersMap } from "@/domain/provider"; import { mergeCls } from "@/utils/css"; -export interface AccessProviderPickerProps { - className?: string; - style?: React.CSSProperties; - autoFocus?: boolean; - gap?: number | "small" | "middle" | "large"; - placeholder?: string; +import { type SharedPickerProps, usePickerWrapperCols } from "./_shared"; + +export interface AccessProviderPickerProps extends SharedPickerProps { showOptionTags?: boolean | { [key in AccessUsageType]?: boolean }; - onFilter?: (value: string, option: AccessProvider) => boolean; - onSelect?: (value: string) => void; } -const AccessProviderPicker = ({ className, style, autoFocus, gap = "middle", placeholder, showOptionTags, onFilter, onSelect }: AccessProviderPickerProps) => { +const AccessProviderPicker = ({ + className, + style, + autoFocus, + gap = "middle", + placeholder, + showOptionTags, + showSearch = true, + onFilter, + onSelect, +}: AccessProviderPickerProps) => { const { t } = useTranslation(); - const wrapperRef = useRef(null); - const wrapperSize = useSize(wrapperRef); - - const [keyword, setKeyword] = useState(); - const keywordInputRef = useRef(null); - useMount(() => { - if (autoFocus) { - setTimeout(() => keywordInputRef.current?.focus(), 1); - } - }); - const showOptionTagForDNS = useMemo(() => { return typeof showOptionTags === "object" ? !!showOptionTags?.[ACCESS_USAGES.DNS] : !!showOptionTags; }, [showOptionTags]); @@ -48,6 +42,16 @@ const AccessProviderPicker = ({ className, style, autoFocus, gap = "middle", pla return showOptionTagForDNS || showOptionTagForHosting || showOptionTagForCA || showOptionTagForNotification; }, [showOptionTagForDNS, showOptionTagForHosting, showOptionTagForCA, showOptionTagForNotification]); + const { wrapperElRef, cols } = usePickerWrapperCols(showOptionTagAnyhow ? 240 : 200); + + const [keyword, setKeyword] = useState(); + const keywordInputRef = useRef(null); + useMount(() => { + if (autoFocus) { + setTimeout(() => keywordInputRef.current?.focus(), 1); + } + }); + const providers = useMemo(() => { return Array.from(accessProvidersMap.values()) .filter((provider) => { @@ -66,86 +70,80 @@ const AccessProviderPicker = ({ className, style, autoFocus, gap = "middle", pla return true; }); }, [onFilter, keyword]); - const providerCols = useMemo(() => { - if (!wrapperSize) { - return 1; - } - - const cols = Math.floor(wrapperSize.width / (showOptionTagAnyhow ? 240 : 200)); - return Math.min(9, Math.max(1, cols)); - }, [wrapperSize, showOptionTagAnyhow]); const handleProviderTypeSelect = (value: string) => { onSelect?.(value); }; return ( -
- setKeyword(e.target.value.trim())} /> +
+ +
+ setKeyword(e.target.value.trim())} /> +
+
-
- 0} fallback={}> -
- {providers.map((provider) => { - return ( -
- { - if (provider.builtin) { - return; - } + 0} fallback={}> +
+ {providers.map((provider) => { + return ( +
+ { + if (provider.builtin) { + return; + } - handleProviderTypeSelect(provider.type); - }} - > -
-
- } shape="square" size={32} /> -
-
-
- - {t(provider.name) || "\u00A0"} - -
- -
- - {t("access.props.provider.builtin")} - - - {t("access.props.provider.usage.dns")} - - - {t("access.props.provider.usage.hosting")} - - - {t("access.props.provider.usage.ca")} - - - {t("access.props.provider.usage.notification")} - -
-
-
+ handleProviderTypeSelect(provider.type); + }} + > +
+
+ } shape="square" size={32} />
- -
- ); - })} -
- -
+
+
+ + {t(provider.name) || "\u00A0"} + +
+ +
+ + {t("access.props.provider.builtin")} + + + {t("access.props.provider.usage.dns")} + + + {t("access.props.provider.usage.hosting")} + + + {t("access.props.provider.usage.ca")} + + + {t("access.props.provider.usage.notification")} + +
+
+
+
+
+
+ ); + })} +
+
); }; diff --git a/ui/src/components/provider/AccessProviderSelect.tsx b/ui/src/components/provider/AccessProviderSelect.tsx index 7b18c9c96..a207144ab 100644 --- a/ui/src/components/provider/AccessProviderSelect.tsx +++ b/ui/src/components/provider/AccessProviderSelect.tsx @@ -1,14 +1,14 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Avatar, Select, type SelectProps, Tag, Typography, theme } from "antd"; +import { Avatar, Select, Tag, Typography, theme } from "antd"; import Show from "@/components/Show"; import { ACCESS_USAGES, type AccessProvider, type AccessUsageType, accessProvidersMap } from "@/domain/provider"; -export interface AccessProviderSelectProps - extends Omit { +import { type SharedSelectProps } from "./_shared"; + +export interface AccessProviderSelectProps extends SharedSelectProps { showOptionTags?: boolean | { [key in AccessUsageType]?: boolean }; - onFilter?: (value: string, option: AccessProvider) => boolean; } const AccessProviderSelect = ({ showOptionTags, onFilter, ...props }: AccessProviderSelectProps = { showOptionTags: true }) => { @@ -16,6 +16,19 @@ const AccessProviderSelect = ({ showOptionTags, onFilter, ...props }: AccessProv const { token: themeToken } = theme.useToken(); + const showOptionTagForDNS = useMemo(() => { + return typeof showOptionTags === "object" ? !!showOptionTags?.[ACCESS_USAGES.DNS] : !!showOptionTags; + }, [showOptionTags]); + const showOptionTagForHosting = useMemo(() => { + return typeof showOptionTags === "object" ? !!showOptionTags?.[ACCESS_USAGES.HOSTING] : !!showOptionTags; + }, [showOptionTags]); + const showOptionTagForCA = useMemo(() => { + return typeof showOptionTags === "object" ? !!showOptionTags?.[ACCESS_USAGES.CA] : !!showOptionTags; + }, [showOptionTags]); + const showOptionTagForNotification = useMemo(() => { + return typeof showOptionTags === "object" ? !!showOptionTags?.[ACCESS_USAGES.NOTIFICATION] : !!showOptionTags; + }, [showOptionTags]); + const options = useMemo>(() => { return Array.from(accessProvidersMap.values()) .filter((provider) => { @@ -34,19 +47,6 @@ const AccessProviderSelect = ({ showOptionTags, onFilter, ...props }: AccessProv })); }, [onFilter]); - const showOptionTagForDNS = useMemo(() => { - return typeof showOptionTags === "object" ? !!showOptionTags?.[ACCESS_USAGES.DNS] : !!showOptionTags; - }, [showOptionTags]); - const showOptionTagForHosting = useMemo(() => { - return typeof showOptionTags === "object" ? !!showOptionTags?.[ACCESS_USAGES.HOSTING] : !!showOptionTags; - }, [showOptionTags]); - const showOptionTagForCA = useMemo(() => { - return typeof showOptionTags === "object" ? !!showOptionTags?.[ACCESS_USAGES.CA] : !!showOptionTags; - }, [showOptionTags]); - const showOptionTagForNotification = useMemo(() => { - return typeof showOptionTags === "object" ? !!showOptionTags?.[ACCESS_USAGES.NOTIFICATION] : !!showOptionTags; - }, [showOptionTags]); - const renderOption = (key: string) => { const provider = accessProvidersMap.get(key) ?? ({ type: "", name: "", icon: "", usages: [] } as unknown as AccessProvider); return ( diff --git a/ui/src/components/provider/CAProviderSelect.tsx b/ui/src/components/provider/CAProviderSelect.tsx index ab89bd065..a8106a9c7 100644 --- a/ui/src/components/provider/CAProviderSelect.tsx +++ b/ui/src/components/provider/CAProviderSelect.tsx @@ -1,13 +1,12 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Avatar, Select, type SelectProps, Typography, theme } from "antd"; +import { Avatar, Select, Typography, theme } from "antd"; import { type CAProvider, caProvidersMap } from "@/domain/provider"; -export interface CAProviderSelectProps - extends Omit { - onFilter?: (value: string, option: CAProvider) => boolean; -} +import { type SharedSelectProps } from "./_shared"; + +export interface CAProviderSelectProps extends SharedSelectProps {} const CAProviderSelect = ({ onFilter, ...props }: CAProviderSelectProps) => { const { t } = useTranslation(); diff --git a/ui/src/components/provider/DeploymentProviderPicker.tsx b/ui/src/components/provider/DeploymentProviderPicker.tsx index bf3c2a7e6..68dbf767a 100644 --- a/ui/src/components/provider/DeploymentProviderPicker.tsx +++ b/ui/src/components/provider/DeploymentProviderPicker.tsx @@ -1,6 +1,6 @@ import { useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useMount, useSize } from "ahooks"; +import { useMount } from "ahooks"; import { Avatar, Card, Checkbox, Empty, Flex, Input, type InputRef, Tabs, Tooltip, Typography } from "antd"; import Show from "@/components/Show"; @@ -9,24 +9,26 @@ import { useZustandShallowSelector } from "@/hooks"; import { useAccessesStore } from "@/stores/access"; import { mergeCls } from "@/utils/css"; -export interface DeploymentProviderPickerProps { - className?: string; - style?: React.CSSProperties; - autoFocus?: boolean; - gap?: number | "small" | "middle" | "large"; - placeholder?: string; - onFilter?: (value: string, option: DeploymentProvider) => boolean; - onSelect?: (value: string) => void; -} +import { type SharedPickerProps, usePickerWrapperCols } from "./_shared"; -const DeploymentProviderPicker = ({ className, style, autoFocus, gap = "middle", placeholder, onFilter, onSelect }: DeploymentProviderPickerProps) => { +export interface DeploymentProviderPickerProps extends SharedPickerProps {} + +const DeploymentProviderPicker = ({ + className, + style, + autoFocus, + gap = "middle", + placeholder, + showSearch = true, + onFilter, + onSelect, +}: DeploymentProviderPickerProps) => { const { t } = useTranslation(); const { accesses, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "fetchAccesses"])); useMount(() => fetchAccesses(false)); - const wrapperRef = useRef(null); - const wrapperSize = useSize(wrapperRef); + const { wrapperElRef, cols } = usePickerWrapperCols(320); const [isAvailableOnly, setIsAvailableOnly] = useState(true); @@ -72,99 +74,91 @@ const DeploymentProviderPicker = ({ className, style, autoFocus, gap = "middle", return true; }); }, [onFilter, accesses, isAvailableOnly, category, keyword]); - const providerCols = useMemo(() => { - if (!wrapperSize) { - return 1; - } - - const cols = Math.floor(wrapperSize.width / 320); - return Math.min(9, Math.max(1, cols)); - }, [wrapperSize]); const handleProviderTypeSelect = (value: string) => { onSelect?.(value); }; return ( -
- setKeyword(e.target.value.trim())} /> +
+ +
+ setKeyword(e.target.value.trim())} /> +
+
-
- - setIsAvailableOnly(!isAvailableOnly)}> - {t("provider.text.show_available_hosting_provider_only")} - - -
+ + setIsAvailableOnly(!isAvailableOnly)}> + {t("provider.text.show_available_provider_only")} + + -
- - ({ - key: key, - label: t(`provider.category.${key}`), - }))} - size="small" - tabBarStyle={{ marginLeft: "-1rem" }} - tabPosition="left" - onChange={(key) => setCategory(key)} - /> + + ({ + key: key, + label: t(`provider.category.${key}`), + }))} + size="small" + tabBarStyle={{ marginLeft: "-1rem" }} + tabPosition="left" + onChange={(key) => setCategory(key)} + /> -
- 0} fallback={}> -
- {providers.map((provider) => { - return ( -
- { - handleProviderTypeSelect(provider.type); - }} - > -
- } shape="square" size={28} /> -
-
- - {t(provider.name) || "\u00A0"} - -
+
+ 0} fallback={}> +
+ {providers.map((provider) => { + return ( +
+ { + handleProviderTypeSelect(provider.type); + }} + > +
+ } shape="square" size={28} /> +
+
+ + {t(provider.name) || "\u00A0"} +
- -
- ); - })} -
- -
- -
+
+ +
+ ); + })} +
+ +
+
); }; diff --git a/ui/src/components/provider/DeploymentProviderSelect.tsx b/ui/src/components/provider/DeploymentProviderSelect.tsx index ffe3d1d3a..cd6f5b6d5 100644 --- a/ui/src/components/provider/DeploymentProviderSelect.tsx +++ b/ui/src/components/provider/DeploymentProviderSelect.tsx @@ -1,13 +1,12 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Avatar, Select, type SelectProps, Typography, theme } from "antd"; +import { Avatar, Select, Typography, theme } from "antd"; import { type DeploymentProvider, deploymentProvidersMap } from "@/domain/provider"; -export interface DeploymentProviderSelectProps - extends Omit { - onFilter?: (value: string, option: DeploymentProvider) => boolean; -} +import { type SharedSelectProps } from "./_shared"; + +export interface DeploymentProviderSelectProps extends SharedSelectProps {} const DeploymentProviderSelect = ({ onFilter, ...props }: DeploymentProviderSelectProps) => { const { t } = useTranslation(); diff --git a/ui/src/components/provider/NotificationProviderPicker.tsx b/ui/src/components/provider/NotificationProviderPicker.tsx new file mode 100644 index 000000000..c7d4310be --- /dev/null +++ b/ui/src/components/provider/NotificationProviderPicker.tsx @@ -0,0 +1,127 @@ +import { useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMount } from "ahooks"; +import { Avatar, Card, Checkbox, Empty, Flex, Input, type InputRef, Tooltip, Typography } from "antd"; + +import Show from "@/components/Show"; +import { type NotificationProvider, notificationProvidersMap } from "@/domain/provider"; +import { useZustandShallowSelector } from "@/hooks"; +import { useAccessesStore } from "@/stores/access"; +import { mergeCls } from "@/utils/css"; + +import { type SharedPickerProps, usePickerWrapperCols } from "./_shared"; + +export interface NotificationProviderPickerProps extends SharedPickerProps {} + +const NotificationProviderPicker = ({ + className, + style, + autoFocus, + gap = "middle", + placeholder, + showSearch = true, + onFilter, + onSelect, +}: NotificationProviderPickerProps) => { + const { t } = useTranslation(); + + const { accesses, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "fetchAccesses"])); + useMount(() => fetchAccesses(false)); + + const { wrapperElRef, cols } = usePickerWrapperCols(320); + + const [isAvailableOnly, setIsAvailableOnly] = useState(true); + + const [keyword, setKeyword] = useState(); + const keywordInputRef = useRef(null); + useMount(() => { + if (autoFocus) { + setTimeout(() => keywordInputRef.current?.focus(), 1); + } + }); + + const providers = useMemo(() => { + return Array.from(notificationProvidersMap.values()) + .filter((provider) => { + if (onFilter) { + return onFilter(provider.type, provider); + } + + return true; + }) + .filter((provider) => { + if (isAvailableOnly) { + return accesses.some((access) => access.provider === provider.provider); + } + + return true; + }) + .filter((provider) => { + if (keyword) { + const value = keyword.toLowerCase(); + return provider.type.toLowerCase().includes(value) || t(provider.name).toLowerCase().includes(value); + } + + return true; + }); + }, [onFilter, accesses, isAvailableOnly, keyword]); + + const handleProviderTypeSelect = (value: string) => { + onSelect?.(value); + }; + + return ( +
+ +
+ setKeyword(e.target.value.trim())} /> +
+
+ + + setIsAvailableOnly(!isAvailableOnly)}> + {t("provider.text.show_available_provider_only")} + + + + 0} fallback={}> +
+ {providers.map((provider) => { + return ( +
+ { + handleProviderTypeSelect(provider.type); + }} + > +
+ } shape="square" size={28} /> +
+
+ + {t(provider.name) || "\u00A0"} + +
+
+
+
+
+ ); + })} +
+
+
+ ); +}; + +export default NotificationProviderPicker; diff --git a/ui/src/components/provider/NotificationProviderSelect.tsx b/ui/src/components/provider/NotificationProviderSelect.tsx index 71b9aee08..f7f86ad16 100644 --- a/ui/src/components/provider/NotificationProviderSelect.tsx +++ b/ui/src/components/provider/NotificationProviderSelect.tsx @@ -1,13 +1,12 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Avatar, Select, type SelectProps, Typography, theme } from "antd"; +import { Avatar, Select, Typography, theme } from "antd"; import { type NotificationProvider, notificationProvidersMap } from "@/domain/provider"; -export interface NotificationProviderSelectProps - extends Omit { - onFilter?: (value: string, option: NotificationProvider) => boolean; -} +import { type SharedSelectProps } from "./_shared"; + +export interface NotificationProviderSelectProps extends SharedSelectProps {} const NotificationProviderSelect = ({ onFilter, ...props }: NotificationProviderSelectProps) => { const { t } = useTranslation(); diff --git a/ui/src/components/provider/_shared.ts b/ui/src/components/provider/_shared.ts new file mode 100644 index 000000000..3c8f32482 --- /dev/null +++ b/ui/src/components/provider/_shared.ts @@ -0,0 +1,42 @@ +import { useMemo, useRef } from "react"; +import { useSize } from "ahooks"; +import { type SelectProps } from "antd"; + +type Provider = { type: string; name: string }; + +export interface SharedSelectProps + extends Omit { + className?: string; + style?: React.CSSProperties; + onFilter?: (value: string, option: T) => boolean; +} + +export interface SharedPickerProps { + className?: string; + style?: React.CSSProperties; + autoFocus?: boolean; + gap?: number | "small" | "middle" | "large"; + placeholder?: string; + showSearch?: boolean; + onFilter?: (value: string, option: T) => boolean; + onSelect?: (value: string) => void; +} + +export const usePickerWrapperCols = (width: number) => { + const wrapperElRef = useRef(null); + const wrapperSize = useSize(wrapperElRef); + + const cols = useMemo(() => { + if (!wrapperSize) { + return 1; + } + + const cols = Math.floor(wrapperSize.width / width); + return Math.min(9, Math.max(1, cols)); + }, [wrapperSize, width]); + + return { + wrapperElRef, + cols, + }; +}; diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigDrawer.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigDrawer.tsx index bab398d4c..2058f9769 100644 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigDrawer.tsx +++ b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigDrawer.tsx @@ -28,7 +28,7 @@ const BizDeployNodeConfigDrawer = ({ node, ...props }: BizDeployNodeConfigDrawer return ( { // 切换部署目标时重置表单,避免其他部署目标的配置字段影响当前部署目标 if (initialValues?.provider === value) { - formInst.resetFields(); + formInst.resetFields(["providerConfig"]); } else { const oldValues = formInst.getFieldsValue(); const newValues: Record = {}; @@ -537,10 +537,7 @@ const BizDeployNodeConfigForm = ({ node, ...props }: BizDeployNodeConfigFormProp showSearch onFilter={(_, option) => { if (option.reserve) return false; - if (fieldProvider) return deploymentProvidersMap.get(fieldProvider)?.provider === option.provider; - - const provider = accessProvidersMap.get(option.provider); - return !!provider?.usages?.includes(ACCESS_USAGES.HOSTING); + return deploymentProvidersMap.get(fieldProvider)?.provider === option.provider; }} /> diff --git a/ui/src/components/workflow/designer/forms/BizNotifyNodeConfigDrawer.tsx b/ui/src/components/workflow/designer/forms/BizNotifyNodeConfigDrawer.tsx index f5b3e247f..15429d28f 100644 --- a/ui/src/components/workflow/designer/forms/BizNotifyNodeConfigDrawer.tsx +++ b/ui/src/components/workflow/designer/forms/BizNotifyNodeConfigDrawer.tsx @@ -23,11 +23,12 @@ const BizNotifyNodeConfigDrawer = ({ node, ...props }: BizNotifyNodeConfigDrawer const [formInst] = Form.useForm(); + const fieldProvider = Form.useWatch("provider", { form: formInst, preserve: true }); + return ( { - // 通常情况下每个授权信息只对应一个消息通知提供商,此时无需显示消息通知提供商字段; - // 如果对应多个,则显示。 - if (fieldProviderAccessId) { - const access = accesses.find((e) => e.id === fieldProviderAccessId); - const providers = Array.from(notificationProvidersMap.values()).filter((e) => e.provider === access?.provider); - setShowProvider(providers.length > 1); - } else { - setShowProvider(false); - } - }, [accesses, fieldProviderAccessId]); - - const handleProviderSelect = (value: string) => { - // 切换消息通知提供商时联动授权信息 - if (initialValues?.provider === value) { - formInst.setFieldValue("providerAccessId", initialValues?.providerAccessId); - } else { - if (notificationProvidersMap.get(fieldProvider)?.provider !== notificationProvidersMap.get(value)?.provider) { - formInst.setFieldValue("providerAccessId", void 0); - } - } + const handleProviderPick = (value: string) => { + formInst.setFieldValue("provider", value); }; - const handleProviderAccessSelect = (value: string) => { - // 切换授权信息时联动消息通知提供商 - const access = accesses.find((access) => access.id === value); - const provider = Array.from(notificationProvidersMap.values()).find((provider) => provider.provider === access?.provider); - if (fieldProvider !== provider?.type) { - formInst.setFieldValue("provider", provider?.type); + const handleProviderSelect = (value?: string | undefined) => { + // 切换通知渠道时重置表单,避免其他通知渠道的配置字段影响当前通知渠道 + if (initialValues?.provider === value) { + formInst.resetFields(["providerConfig"]); + } else { + if (notificationProvidersMap.get(fieldProvider)?.provider !== notificationProvidersMap.get(value!)?.provider) { + formInst.setFieldValue("providerAccessId", void 0); + } } }; return ( -
- - - + + + - - - -
+
+
+ + + -
- - - {t("workflow_node.notify.form_anchor.channel.title")} - - + + + +
-
diff --git a/ui/src/i18n/locales/en/nls.provider.json b/ui/src/i18n/locales/en/nls.provider.json index 0607dbbfa..6cf80f38a 100644 --- a/ui/src/i18n/locales/en/nls.provider.json +++ b/ui/src/i18n/locales/en/nls.provider.json @@ -194,6 +194,7 @@ "provider.category.nas": "NAS", "provider.category.other": "Other", + "provider.text.nodata": "No providers available", "provider.text.default_ca_provider": "(Default) Follow global settings", - "provider.text.show_available_hosting_provider_only": "Only display available providers (with added credentials)" + "provider.text.show_available_provider_only": "Only display available providers (with added credentials)" } diff --git a/ui/src/i18n/locales/en/nls.workflow.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json index 6971b368c..bbef59cf8 100644 --- a/ui/src/i18n/locales/en/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/en/nls.workflow.nodes.json @@ -147,9 +147,9 @@ "workflow_node.deploy.form.certificate_output_node_id.label": "Certificate to deploy", "workflow_node.deploy.form.certificate_output_node_id.placeholder": "Please select certificate to deploy", "workflow_node.deploy.form.certificate_output_node_id.help": "Notes: The certificate to be deployed comes from the previous nodes of application or upload.", - "workflow_node.deploy.form.provider.label": "Deploy target", - "workflow_node.deploy.form.provider.placeholder": "Please select deploy target", - "workflow_node.deploy.form.provider.search.placeholder": "Search deploy target ...", + "workflow_node.deploy.form.provider.label": "Deployment target", + "workflow_node.deploy.form.provider.placeholder": "Please select deployment target", + "workflow_node.deploy.form.provider.search.placeholder": "Search deployment target ...", "workflow_node.deploy.form.provider_access.label": "Hosting provider credential", "workflow_node.deploy.form.provider_access.placeholder": "Please select an credential of Hosting provider", "workflow_node.deploy.form.provider_access.tooltip": "Used to invoke API during deployment.", @@ -997,6 +997,7 @@ "workflow_node.notify.form.message.placeholder": "Please enter message", "workflow_node.notify.form.provider.label": "Notification channel", "workflow_node.notify.form.provider.placeholder": "Please select notification channel", + "workflow_node.notify.form.provider.search.placeholder": "Search notification channel ...", "workflow_node.notify.form.provider_access.label": "Notification provider credential", "workflow_node.notify.form.provider_access.placeholder": "Please select an credential of notification provider", "workflow_node.notify.form.provider_access.button": "Create", diff --git a/ui/src/i18n/locales/zh/nls.provider.json b/ui/src/i18n/locales/zh/nls.provider.json index 2f1b64297..f92dcbf0c 100644 --- a/ui/src/i18n/locales/zh/nls.provider.json +++ b/ui/src/i18n/locales/zh/nls.provider.json @@ -194,6 +194,7 @@ "provider.category.nas": "NAS", "provider.category.other": "其他", + "provider.text.nodata": "暂无提供商", "provider.text.default_ca_provider": "(默认)不指定,跟随全局设置", - "provider.text.show_available_hosting_provider_only": "仅列出可用的提供商(即已添加过授权凭据的)" + "provider.text.show_available_provider_only": "仅列出可用的提供商(即已添加过授权凭据的)" } diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index a83826c03..dba294320 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -995,6 +995,7 @@ "workflow_node.notify.form.message.placeholder": "请输入通知内容", "workflow_node.notify.form.provider.label": "通知渠道", "workflow_node.notify.form.provider.placeholder": "请选择通知渠道", + "workflow_node.notify.form.provider.search.placeholder": "搜索通知渠道……", "workflow_node.notify.form.provider_access.label": "通知渠道授权", "workflow_node.notify.form.provider_access.placeholder": "请选择通知渠道授权", "workflow_node.notify.form.provider_access.button": "新建", From fb4dd929cad764a702df9884159a8982980f4883 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Thu, 4 Sep 2025 20:28:02 +0800 Subject: [PATCH 6/9] feat(ui): display both available and unavailable providers --- .../provider/DeploymentProviderPicker.tsx | 132 +++++++++--------- .../provider/NotificationProviderPicker.tsx | 125 ++++++++--------- ui/src/components/provider/_shared.ts | 75 +++++++++- ui/src/i18n/locales/en/nls.provider.json | 2 +- ui/src/i18n/locales/zh/nls.provider.json | 2 +- 5 files changed, 197 insertions(+), 139 deletions(-) diff --git a/ui/src/components/provider/DeploymentProviderPicker.tsx b/ui/src/components/provider/DeploymentProviderPicker.tsx index 68dbf767a..193bb6da0 100644 --- a/ui/src/components/provider/DeploymentProviderPicker.tsx +++ b/ui/src/components/provider/DeploymentProviderPicker.tsx @@ -1,17 +1,17 @@ -import { useMemo, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMount } from "ahooks"; -import { Avatar, Card, Checkbox, Empty, Flex, Input, type InputRef, Tabs, Tooltip, Typography } from "antd"; +import { Avatar, Card, Divider, Empty, Flex, Input, type InputRef, Tabs, Tooltip, Typography } from "antd"; import Show from "@/components/Show"; import { DEPLOYMENT_CATEGORIES, type DeploymentProvider, deploymentProvidersMap } from "@/domain/provider"; -import { useZustandShallowSelector } from "@/hooks"; -import { useAccessesStore } from "@/stores/access"; import { mergeCls } from "@/utils/css"; -import { type SharedPickerProps, usePickerWrapperCols } from "./_shared"; +import { type SharedPickerProps, usePickerDataSource, usePickerWrapperCols } from "./_shared"; -export interface DeploymentProviderPickerProps extends SharedPickerProps {} +export interface DeploymentProviderPickerProps extends SharedPickerProps { + showAvailable?: boolean; +} const DeploymentProviderPicker = ({ className, @@ -19,19 +19,15 @@ const DeploymentProviderPicker = ({ autoFocus, gap = "middle", placeholder, + showAvailable = true, showSearch = true, onFilter, onSelect, }: DeploymentProviderPickerProps) => { const { t } = useTranslation(); - const { accesses, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "fetchAccesses"])); - useMount(() => fetchAccesses(false)); - const { wrapperElRef, cols } = usePickerWrapperCols(320); - const [isAvailableOnly, setIsAvailableOnly] = useState(true); - const [category, setCategory] = useState(DEPLOYMENT_CATEGORIES.ALL); const [keyword, setKeyword] = useState(); @@ -42,38 +38,47 @@ const DeploymentProviderPicker = ({ } }); - const providers = useMemo(() => { - return Array.from(deploymentProvidersMap.values()) - .filter((provider) => { - if (onFilter) { - return onFilter(provider.type, provider); - } - - return true; - }) - .filter((provider) => { - if (isAvailableOnly) { - return provider.builtin || accesses.some((access) => access.provider === provider.provider); - } - - return true; - }) - .filter((provider) => { + const dataSources = usePickerDataSource({ + dataSource: Array.from(deploymentProvidersMap.values()), + filters: [ + (provider) => { if (category && category !== DEPLOYMENT_CATEGORIES.ALL) { return provider.category === category; } return true; - }) - .filter((provider) => { - if (keyword) { - const value = keyword.toLowerCase(); - return provider.type.toLowerCase().includes(value) || t(provider.name).toLowerCase().includes(value); - } + }, + ], + keyword: keyword, + onFilter: onFilter, + deps: [category], + }); - return true; - }); - }, [onFilter, accesses, isAvailableOnly, category, keyword]); + const renderOption = (provider: DeploymentProvider) => { + return ( +
+ { + handleProviderTypeSelect(provider.type); + }} + > +
+ } shape="square" size={28} /> +
+
+ + {t(provider.name) || "\u00A0"} + +
+
+
+
+
+ ); + }; const handleProviderTypeSelect = (value: string) => { onSelect?.(value); @@ -87,12 +92,6 @@ const DeploymentProviderPicker = ({
- - setIsAvailableOnly(!isAvailableOnly)}> - {t("provider.text.show_available_provider_only")} - - -
- 0} fallback={}> + 0} fallback={}>
- {providers.map((provider) => { - return ( -
- { - handleProviderTypeSelect(provider.type); - }} - > -
- } shape="square" size={28} /> -
-
- - {t(provider.name) || "\u00A0"} - -
-
-
-
-
- ); - })} + {(showAvailable ? dataSources.available : dataSources.filtered).map((provider) => renderOption(provider))}
+ + 0}> + + + {t("provider.text.unavailable_options")} + + + +
+ {dataSources.unavailable.map((provider) => renderOption(provider))} +
+
diff --git a/ui/src/components/provider/NotificationProviderPicker.tsx b/ui/src/components/provider/NotificationProviderPicker.tsx index c7d4310be..2a6bdc45e 100644 --- a/ui/src/components/provider/NotificationProviderPicker.tsx +++ b/ui/src/components/provider/NotificationProviderPicker.tsx @@ -1,17 +1,17 @@ -import { useMemo, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMount } from "ahooks"; -import { Avatar, Card, Checkbox, Empty, Flex, Input, type InputRef, Tooltip, Typography } from "antd"; +import { Avatar, Card, Divider, Empty, Input, type InputRef, Tooltip, Typography } from "antd"; import Show from "@/components/Show"; import { type NotificationProvider, notificationProvidersMap } from "@/domain/provider"; -import { useZustandShallowSelector } from "@/hooks"; -import { useAccessesStore } from "@/stores/access"; import { mergeCls } from "@/utils/css"; -import { type SharedPickerProps, usePickerWrapperCols } from "./_shared"; +import { type SharedPickerProps, usePickerDataSource, usePickerWrapperCols } from "./_shared"; -export interface NotificationProviderPickerProps extends SharedPickerProps {} +export interface NotificationProviderPickerProps extends SharedPickerProps { + showAvailable?: boolean; +} const NotificationProviderPicker = ({ className, @@ -19,19 +19,15 @@ const NotificationProviderPicker = ({ autoFocus, gap = "middle", placeholder, + showAvailable = true, showSearch = true, onFilter, onSelect, }: NotificationProviderPickerProps) => { const { t } = useTranslation(); - const { accesses, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "fetchAccesses"])); - useMount(() => fetchAccesses(false)); - const { wrapperElRef, cols } = usePickerWrapperCols(320); - const [isAvailableOnly, setIsAvailableOnly] = useState(true); - const [keyword, setKeyword] = useState(); const keywordInputRef = useRef(null); useMount(() => { @@ -40,31 +36,37 @@ const NotificationProviderPicker = ({ } }); - const providers = useMemo(() => { - return Array.from(notificationProvidersMap.values()) - .filter((provider) => { - if (onFilter) { - return onFilter(provider.type, provider); - } + const dataSources = usePickerDataSource({ + dataSource: Array.from(notificationProvidersMap.values()), + keyword: keyword, + onFilter: onFilter, + }); - return true; - }) - .filter((provider) => { - if (isAvailableOnly) { - return accesses.some((access) => access.provider === provider.provider); - } - - return true; - }) - .filter((provider) => { - if (keyword) { - const value = keyword.toLowerCase(); - return provider.type.toLowerCase().includes(value) || t(provider.name).toLowerCase().includes(value); - } - - return true; - }); - }, [onFilter, accesses, isAvailableOnly, keyword]); + const renderOption = (provider: NotificationProvider) => { + return ( +
+ { + handleProviderTypeSelect(provider.type); + }} + > +
+ } shape="square" size={28} /> +
+
+ + {t(provider.name) || "\u00A0"} + +
+
+
+
+
+ ); + }; const handleProviderTypeSelect = (value: string) => { onSelect?.(value); @@ -78,13 +80,7 @@ const NotificationProviderPicker = ({
- - setIsAvailableOnly(!isAvailableOnly)}> - {t("provider.text.show_available_provider_only")} - - - - 0} fallback={}> + 0} fallback={}>
- {providers.map((provider) => { - return ( -
- { - handleProviderTypeSelect(provider.type); - }} - > -
- } shape="square" size={28} /> -
-
- - {t(provider.name) || "\u00A0"} - -
-
-
-
-
- ); - })} + {(showAvailable ? dataSources.available : dataSources.filtered).map((provider) => renderOption(provider))}
+ + 0}> + + + {t("provider.text.unavailable_options")} + + + +
+ {dataSources.unavailable.map((provider) => renderOption(provider))} +
+
); diff --git a/ui/src/components/provider/_shared.ts b/ui/src/components/provider/_shared.ts index 3c8f32482..ff345c880 100644 --- a/ui/src/components/provider/_shared.ts +++ b/ui/src/components/provider/_shared.ts @@ -1,7 +1,12 @@ import { useMemo, useRef } from "react"; -import { useSize } from "ahooks"; +import { useTranslation } from "react-i18next"; +import { useMount, useSize } from "ahooks"; + import { type SelectProps } from "antd"; +import { useZustandShallowSelector } from "@/hooks"; +import { useAccessesStore } from "@/stores/access"; + type Provider = { type: string; name: string }; export interface SharedSelectProps @@ -40,3 +45,71 @@ export const usePickerWrapperCols = (width: number) => { cols, }; }; + +export const usePickerDataSource = ({ + dataSource, + filters, + keyword, + onFilter, + deps, +}: { + dataSource: T[]; + filters?: Array<(option: T) => boolean>; + keyword?: string; + onFilter?: (value: string, option: T) => boolean; + deps?: React.DependencyList; +}) => { + const { t } = useTranslation(); + + const { accesses, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "fetchAccesses"])); + useMount(() => fetchAccesses(false)); + + const filteredDataSource = useMemo(() => { + return dataSource + .filter((provider) => { + if (onFilter) { + return onFilter(provider.type, provider); + } + + return true; + }) + .filter((provider) => { + if (filters) { + for (const filter of filters) { + if (!filter(provider)) return false; + } + } + + return true; + }) + .filter((provider) => { + if (keyword) { + const value = keyword.toLowerCase(); + return provider.type.toLowerCase().includes(value) || t(provider.name).toLowerCase().includes(value); + } + + return true; + }); + }, [dataSource, filters, keyword, onFilter, ...(deps ?? [])]); + + const availableDataSource = useMemo(() => { + return filteredDataSource.filter((provider) => { + return accesses.some((access) => { + if ("builtin" in provider && provider.builtin) return true; + if ("provider" in provider) return access.provider === provider.provider; + return access.provider === provider.type; + }); + }); + }, [accesses, filteredDataSource, ...(deps ?? [])]); + + const unavailableDataSource = useMemo(() => { + return filteredDataSource.filter((item) => !availableDataSource.includes(item)); + }, [filteredDataSource, availableDataSource, ...(deps ?? [])]); + + return { + all: dataSource, + filtered: filteredDataSource, + available: availableDataSource, + unavailable: unavailableDataSource, + }; +}; diff --git a/ui/src/i18n/locales/en/nls.provider.json b/ui/src/i18n/locales/en/nls.provider.json index 6cf80f38a..12b05b211 100644 --- a/ui/src/i18n/locales/en/nls.provider.json +++ b/ui/src/i18n/locales/en/nls.provider.json @@ -196,5 +196,5 @@ "provider.text.nodata": "No providers available", "provider.text.default_ca_provider": "(Default) Follow global settings", - "provider.text.show_available_provider_only": "Only display available providers (with added credentials)" + "provider.text.unavailable_options": "The following providers are not available (without added credentials)" } diff --git a/ui/src/i18n/locales/zh/nls.provider.json b/ui/src/i18n/locales/zh/nls.provider.json index f92dcbf0c..5ab9407cf 100644 --- a/ui/src/i18n/locales/zh/nls.provider.json +++ b/ui/src/i18n/locales/zh/nls.provider.json @@ -196,5 +196,5 @@ "provider.text.nodata": "暂无提供商", "provider.text.default_ca_provider": "(默认)不指定,跟随全局设置", - "provider.text.show_available_provider_only": "仅列出可用的提供商(即已添加过授权凭据的)" + "provider.text.unavailable_options": "以下提供商不可用(即未添加过授权凭据)" } From 3d5f2f4bfccff80905361410edb13b1a4a7b445d Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Thu, 4 Sep 2025 20:56:58 +0800 Subject: [PATCH 7/9] feat(ui): auto set provider access after pick provider --- .../forms/BizDeployNodeConfigForm.tsx | 53 +++++++++++-------- .../forms/BizNotifyNodeConfigForm.tsx | 38 ++++++++----- 2 files changed, 56 insertions(+), 35 deletions(-) diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigForm.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigForm.tsx index d88acb81f..ce15be7ab 100644 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigForm.tsx +++ b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigForm.tsx @@ -11,9 +11,11 @@ import AccessSelect from "@/components/access/AccessSelect"; import DeploymentProviderPicker from "@/components/provider/DeploymentProviderPicker"; import DeploymentProviderSelect from "@/components/provider/DeploymentProviderSelect"; import Show from "@/components/Show"; +import { type AccessModel } from "@/domain/access"; import { ACCESS_USAGES, DEPLOYMENT_PROVIDERS, accessProvidersMap, deploymentProvidersMap } from "@/domain/provider"; import { type WorkflowNodeConfigForBizDeploy, defaultNodeConfigForBizDeploy } from "@/domain/workflow"; -import { useAntdForm } from "@/hooks"; +import { useAntdForm, useZustandShallowSelector } from "@/hooks"; +import { useAccessesStore } from "@/stores/access"; import { getAllPreviousNodes } from "../_util"; import { FormNestedFieldsContextProvider, NodeFormContextProvider } from "./_context"; @@ -126,6 +128,12 @@ const BizDeployNodeConfigForm = ({ node, ...props }: BizDeployNodeConfigFormProp const { token: themeToken } = theme.useToken(); + const { accesses } = useAccessesStore(useZustandShallowSelector("accesses")); + const accessOptionFilter = (_: string, option: AccessModel) => { + if (option.reserve) return false; + return deploymentProvidersMap.get(fieldProvider)?.provider === option.provider; + }; + const initialValues = useMemo(() => { return getNodeForm(node)?.getValueIn("config") as WorkflowNodeConfigForBizDeploy | undefined; }, [node]); @@ -149,6 +157,7 @@ const BizDeployNodeConfigForm = ({ node, ...props }: BizDeployNodeConfigFormProp }); const fieldProvider = Form.useWatch("provider", { form: formInst, preserve: true }); + const fieldProviderAccessId = Form.useWatch("providerAccessId", { form: formInst, preserve: true }); const certificateOutputNodeIdOptions = useMemo(() => { return getAllPreviousNodes(node) @@ -460,6 +469,24 @@ const BizDeployNodeConfigForm = ({ node, ...props }: BizDeployNodeConfigFormProp } }, [fieldProvider]); + useEffect(() => { + // 如果未选择部署目标,则清空授权信息 + if (!fieldProvider && fieldProviderAccessId) { + formInst.setFieldValue("providerAccessId", void 0); + return; + } + + // 如果已选择部署目标只有一个授权信息,则自动选择该授权信息 + if (fieldProvider && !fieldProviderAccessId) { + const availableAccesses = accesses + .filter((access) => accessOptionFilter(access.provider, access)) + .filter((access) => access.provider === deploymentProvidersMap.get(fieldProvider)?.provider); + if (availableAccesses.length === 1) { + formInst.setFieldValue("providerAccessId", availableAccesses[0].id); + } + } + }, [fieldProvider, fieldProviderAccessId]); + const handleProviderPick = (value: string) => { formInst.setFieldValue("provider", value); }; @@ -469,20 +496,7 @@ const BizDeployNodeConfigForm = ({ node, ...props }: BizDeployNodeConfigFormProp if (initialValues?.provider === value) { formInst.resetFields(["providerConfig"]); } else { - const oldValues = formInst.getFieldsValue(); - const newValues: Record = {}; - for (const key in oldValues) { - if (key === "certificateOutputNodeId" || key === "provider" || key === "providerAccessId" || key === "skipOnLastSucceeded") { - newValues[key] = oldValues[key]; - } else { - delete newValues[key]; - } - } - formInst.setFieldsValue(newValues); - - if (deploymentProvidersMap.get(fieldProvider)?.provider !== deploymentProvidersMap.get(value!)?.provider) { - formInst.setFieldValue("providerAccessId", void 0); - } + formInst.setFieldValue("providerConfig", void 0); } }; @@ -532,14 +546,7 @@ const BizDeployNodeConfigForm = ({ node, ...props }: BizDeployNodeConfigFormProp />
- { - if (option.reserve) return false; - return deploymentProvidersMap.get(fieldProvider)?.provider === option.provider; - }} - /> + diff --git a/ui/src/components/workflow/designer/forms/BizNotifyNodeConfigForm.tsx b/ui/src/components/workflow/designer/forms/BizNotifyNodeConfigForm.tsx index 8e7733f6a..57c4791d9 100644 --- a/ui/src/components/workflow/designer/forms/BizNotifyNodeConfigForm.tsx +++ b/ui/src/components/workflow/designer/forms/BizNotifyNodeConfigForm.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { getI18n, useTranslation } from "react-i18next"; import { type FlowNodeEntity, getNodeForm } from "@flowgram.ai/fixed-layout-editor"; import { IconPlus } from "@tabler/icons-react"; @@ -11,6 +11,7 @@ import AccessSelect from "@/components/access/AccessSelect"; import NotificationProviderPicker from "@/components/provider/NotificationProviderPicker"; import NotificationProviderSelect from "@/components/provider/NotificationProviderSelect"; import Show from "@/components/Show"; +import { type AccessModel } from "@/domain/access"; import { ACCESS_USAGES, NOTIFICATION_PROVIDERS, accessProvidersMap, notificationProvidersMap } from "@/domain/provider"; import { type WorkflowNodeConfigForBizNotify, defaultNodeConfigForBizNotify } from "@/domain/workflow"; import { useAntdForm, useZustandShallowSelector } from "@/hooks"; @@ -38,6 +39,10 @@ const BizNotifyNodeConfigForm = ({ node, ...props }: BizNotifyNodeConfigFormProp const { i18n, t } = useTranslation(); const { accesses } = useAccessesStore(useZustandShallowSelector("accesses")); + const accessOptionFilter = (_: string, option: AccessModel) => { + if (option.reserve !== "notif") return false; + return notificationProvidersMap.get(fieldProvider)?.provider === option.provider; + }; const initialValues = useMemo(() => { return getNodeForm(node)?.getValueIn("config") as WorkflowNodeConfigForBizNotify | undefined; @@ -81,6 +86,24 @@ const BizNotifyNodeConfigForm = ({ node, ...props }: BizNotifyNodeConfigFormProp } }, [fieldProvider]); + useEffect(() => { + // 如果未选择通知渠道,则清空授权信息 + if (!fieldProvider && fieldProviderAccessId) { + formInst.setFieldValue("providerAccessId", void 0); + return; + } + + // 如果已选择通知渠道只有一个授权信息,则自动选择该授权信息 + if (fieldProvider && !fieldProviderAccessId) { + const availableAccesses = accesses + .filter((access) => accessOptionFilter(access.provider, access)) + .filter((access) => access.provider === notificationProvidersMap.get(fieldProvider)?.provider); + if (availableAccesses.length === 1) { + formInst.setFieldValue("providerAccessId", availableAccesses[0].id); + } + } + }, [fieldProvider, fieldProviderAccessId]); + const handleProviderPick = (value: string) => { formInst.setFieldValue("provider", value); }; @@ -90,9 +113,7 @@ const BizNotifyNodeConfigForm = ({ node, ...props }: BizNotifyNodeConfigFormProp if (initialValues?.provider === value) { formInst.resetFields(["providerConfig"]); } else { - if (notificationProvidersMap.get(fieldProvider)?.provider !== notificationProvidersMap.get(value!)?.provider) { - formInst.setFieldValue("providerAccessId", void 0); - } + formInst.setFieldValue("providerConfig", void 0); } }; @@ -152,14 +173,7 @@ const BizNotifyNodeConfigForm = ({ node, ...props }: BizNotifyNodeConfigFormProp />
- { - if (option.reserve !== "notif") return false; - return notificationProvidersMap.get(fieldProvider)?.provider === option.provider; - }} - /> + From 9139a45699d73f3b6e9d30e81ec9e48cb34d5eca Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Fri, 5 Sep 2025 09:06:09 +0800 Subject: [PATCH 8/9] feat(ui): enhance providers select --- ui/src/components/MultipleSplitValueInput.tsx | 2 +- ui/src/components/access/AccessEditDrawer.tsx | 1 + ui/src/components/access/AccessForm.tsx | 2 +- .../provider/AccessProviderPicker.tsx | 127 +++++++------- .../provider/DeploymentProviderPicker.tsx | 38 ++-- .../provider/DeploymentProviderSelect.tsx | 58 ++++--- .../provider/NotificationProviderPicker.tsx | 36 ++-- .../provider/NotificationProviderSelect.tsx | 58 ++++--- ui/src/components/provider/_shared.ts | 162 +++++++++++------- .../designer/forms/BizApplyNodeConfigForm.tsx | 66 +++++-- .../forms/BizDeployNodeConfigForm.tsx | 77 +++++---- .../forms/BizMonitorNodeConfigForm.tsx | 2 +- .../forms/BizNotifyNodeConfigForm.tsx | 11 +- .../forms/BizUploadNodeConfigForm.tsx | 2 +- .../forms/BranchBlockNodeConfigForm.tsx | 2 +- .../designer/forms/DelayNodeConfigForm.tsx | 2 +- .../designer/forms/StartNodeConfigForm.tsx | 2 +- ui/src/domain/workflow.ts | 1 - ui/src/hooks/useAntdForm.ts | 2 +- ui/src/i18n/locales/en/nls.provider.json | 4 +- ui/src/i18n/locales/zh/nls.provider.json | 4 +- ui/src/pages/accesses/AccessNew.tsx | 1 + 22 files changed, 385 insertions(+), 275 deletions(-) diff --git a/ui/src/components/MultipleSplitValueInput.tsx b/ui/src/components/MultipleSplitValueInput.tsx index d161c8474..e09082e2c 100644 --- a/ui/src/components/MultipleSplitValueInput.tsx +++ b/ui/src/components/MultipleSplitValueInput.tsx @@ -56,7 +56,7 @@ const MultipleSplitValueInput = ({ name: "componentMultipleSplitValueInput_" + nanoid(), initialValues: { value: value?.split(delimiter) }, onSubmit: (values) => { - const temp = values.value ?? []; + const temp = (values.value ?? []) as string[]; if (splitOptions.trimSpace) { temp.map((e) => e.trim()); } diff --git a/ui/src/components/access/AccessEditDrawer.tsx b/ui/src/components/access/AccessEditDrawer.tsx index 29c02c12f..df5a2c26d 100644 --- a/ui/src/components/access/AccessEditDrawer.tsx +++ b/ui/src/components/access/AccessEditDrawer.tsx @@ -146,6 +146,7 @@ const AccessEditDrawer = ({ afterClose, afterSubmit, mode, data, loading, trigge gap="large" placeholder={t("access.form.provider.search.placeholder")} showOptionTags={usage == null || (usage === "dns-hosting" ? { [ACCESS_USAGES.DNS]: true, [ACCESS_USAGES.HOSTING]: true } : false)} + showSearch onFilter={providerFilter} onSelect={handleProviderPick} /> diff --git a/ui/src/components/access/AccessForm.tsx b/ui/src/components/access/AccessForm.tsx index 742782a87..976434ae7 100644 --- a/ui/src/components/access/AccessForm.tsx +++ b/ui/src/components/access/AccessForm.tsx @@ -112,7 +112,7 @@ const AccessForm = ({ className, style, disabled, initialValues, mode, usage, .. reserve: z.string().nullish(), }); const formRule = createSchemaFieldRule(formSchema); - const { form: formInst, formProps } = useAntdForm({ + const { form: formInst, formProps } = useAntdForm>({ form: props.form, name: "accessForm", initialValues: initialValues, diff --git a/ui/src/components/provider/AccessProviderPicker.tsx b/ui/src/components/provider/AccessProviderPicker.tsx index 3b7f1c5aa..6620c43c1 100644 --- a/ui/src/components/provider/AccessProviderPicker.tsx +++ b/ui/src/components/provider/AccessProviderPicker.tsx @@ -7,7 +7,7 @@ import Show from "@/components/Show"; import { ACCESS_USAGES, type AccessProvider, type AccessUsageType, accessProvidersMap } from "@/domain/provider"; import { mergeCls } from "@/utils/css"; -import { type SharedPickerProps, usePickerWrapperCols } from "./_shared"; +import { type SharedPickerProps, usePickerDataSource, usePickerWrapperCols } from "./_shared"; export interface AccessProviderPickerProps extends SharedPickerProps { showOptionTags?: boolean | { [key in AccessUsageType]?: boolean }; @@ -20,7 +20,7 @@ const AccessProviderPicker = ({ gap = "middle", placeholder, showOptionTags, - showSearch = true, + showSearch = false, onFilter, onSelect, }: AccessProviderPickerProps) => { @@ -52,24 +52,62 @@ const AccessProviderPicker = ({ } }); - const providers = useMemo(() => { - return Array.from(accessProvidersMap.values()) - .filter((provider) => { - if (onFilter) { - return onFilter(provider.type, provider); - } + const dataSources = usePickerDataSource({ + dataSource: Array.from(accessProvidersMap.values()), + filters: [onFilter!], + keyword: keyword, + }); - return true; - }) - .filter((provider) => { - if (keyword) { - const value = keyword.toLowerCase(); - return provider.type.toLowerCase().includes(value) || t(provider.name).toLowerCase().includes(value); - } + const renderOption = (provider: AccessProvider) => { + return ( +
+ { + if (provider.builtin) { + return; + } - return true; - }); - }, [onFilter, keyword]); + handleProviderTypeSelect(provider.type); + }} + > +
+
+ } shape="square" size={32} /> +
+
+
+ + {t(provider.name) || "\u00A0"} + +
+ +
+ + {t("access.props.provider.builtin")} + + + {t("access.props.provider.usage.dns")} + + + {t("access.props.provider.usage.hosting")} + + + {t("access.props.provider.usage.ca")} + + + {t("access.props.provider.usage.notification")} + +
+
+
+
+
+
+ ); + }; const handleProviderTypeSelect = (value: string) => { onSelect?.(value); @@ -83,7 +121,7 @@ const AccessProviderPicker = ({
- 0} fallback={}> + 0} fallback={}>
- {providers.map((provider) => { - return ( -
- { - if (provider.builtin) { - return; - } - - handleProviderTypeSelect(provider.type); - }} - > -
-
- } shape="square" size={32} /> -
-
-
- - {t(provider.name) || "\u00A0"} - -
- -
- - {t("access.props.provider.builtin")} - - - {t("access.props.provider.usage.dns")} - - - {t("access.props.provider.usage.hosting")} - - - {t("access.props.provider.usage.ca")} - - - {t("access.props.provider.usage.notification")} - -
-
-
-
-
-
- ); - })} + {dataSources.filtered.map((provider) => renderOption(provider))}
diff --git a/ui/src/components/provider/DeploymentProviderPicker.tsx b/ui/src/components/provider/DeploymentProviderPicker.tsx index 193bb6da0..61e4b98d7 100644 --- a/ui/src/components/provider/DeploymentProviderPicker.tsx +++ b/ui/src/components/provider/DeploymentProviderPicker.tsx @@ -10,7 +10,7 @@ import { mergeCls } from "@/utils/css"; import { type SharedPickerProps, usePickerDataSource, usePickerWrapperCols } from "./_shared"; export interface DeploymentProviderPickerProps extends SharedPickerProps { - showAvailable?: boolean; + showAvailability?: boolean; } const DeploymentProviderPicker = ({ @@ -19,8 +19,8 @@ const DeploymentProviderPicker = ({ autoFocus, gap = "middle", placeholder, - showAvailable = true, - showSearch = true, + showAvailability = false, + showSearch = false, onFilter, onSelect, }: DeploymentProviderPickerProps) => { @@ -41,7 +41,8 @@ const DeploymentProviderPicker = ({ const dataSources = usePickerDataSource({ dataSource: Array.from(deploymentProvidersMap.values()), filters: [ - (provider) => { + onFilter!, + (_, provider) => { if (category && category !== DEPLOYMENT_CATEGORIES.ALL) { return provider.category === category; } @@ -50,28 +51,29 @@ const DeploymentProviderPicker = ({ }, ], keyword: keyword, - onFilter: onFilter, deps: [category], }); - const renderOption = (provider: DeploymentProvider) => { + const renderOption = (provider: DeploymentProvider, transparent: boolean = false) => { return (
{ handleProviderTypeSelect(provider.type); }} > -
- } shape="square" size={28} /> -
-
- - {t(provider.name) || "\u00A0"} - +
+
+ } shape="square" size={28} /> +
+
+ + {t(provider.name) || "\u00A0"} + +
@@ -129,13 +131,13 @@ const DeploymentProviderPicker = ({ [`gap-${+gap || "2"}`]: typeof gap === "number", })} > - {(showAvailable ? dataSources.available : dataSources.filtered).map((provider) => renderOption(provider))} + {(showAvailability ? dataSources.available : dataSources.filtered).map((provider) => renderOption(provider))}
- 0}> + 0}> - {t("provider.text.unavailable_options")} + {t("provider.text.unavailable_divider")} @@ -147,7 +149,7 @@ const DeploymentProviderPicker = ({ [`gap-${+gap || "2"}`]: typeof gap === "number", })} > - {dataSources.unavailable.map((provider) => renderOption(provider))} + {dataSources.unavailable.map((provider) => renderOption(provider, true))}
diff --git a/ui/src/components/provider/DeploymentProviderSelect.tsx b/ui/src/components/provider/DeploymentProviderSelect.tsx index cd6f5b6d5..285e0e690 100644 --- a/ui/src/components/provider/DeploymentProviderSelect.tsx +++ b/ui/src/components/provider/DeploymentProviderSelect.tsx @@ -1,34 +1,31 @@ -import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Avatar, Select, Typography, theme } from "antd"; import { type DeploymentProvider, deploymentProvidersMap } from "@/domain/provider"; -import { type SharedSelectProps } from "./_shared"; +import { type SharedSelectProps, useSelectDataSource } from "./_shared"; -export interface DeploymentProviderSelectProps extends SharedSelectProps {} +export interface DeploymentProviderSelectProps extends SharedSelectProps { + showAvailability?: boolean; +} -const DeploymentProviderSelect = ({ onFilter, ...props }: DeploymentProviderSelectProps) => { +const DeploymentProviderSelect = ({ showAvailability = false, onFilter, ...props }: DeploymentProviderSelectProps) => { const { t } = useTranslation(); const { token: themeToken } = theme.useToken(); - const options = useMemo>(() => { - return Array.from(deploymentProvidersMap.values()) - .filter((provider) => { - if (onFilter) { - return onFilter(provider.type, provider); - } - - return true; - }) - .map((provider) => ({ - key: provider.type, - value: provider.type, - label: t(provider.name), - data: provider, - })); - }, [onFilter]); + const dataSources = useSelectDataSource({ + dataSource: Array.from(deploymentProvidersMap.values()), + filters: [onFilter!], + }); + const dataSource2Options = (providers: DeploymentProvider[]): Array<{ key: string; value: string; label: string; data: DeploymentProvider }> => { + return providers.map((provider) => ({ + key: provider.type, + value: provider.type, + label: t(provider.name), + data: provider, + })); + }; const renderOption = (key: string) => { const provider = deploymentProvidersMap.get(key); @@ -45,9 +42,11 @@ const DeploymentProviderSelect = ({ onFilter, ...props }: DeploymentProviderSele {...props} filterOption={(inputValue, option) => { if (!option) return false; + if (!option.label) return false; + if (!option.value) return false; const value = inputValue.toLowerCase(); - return option.value.toLowerCase().includes(value) || option.label.toLowerCase().includes(value); + return String(option.value).toLowerCase().includes(value) || String(option.label).toLowerCase().includes(value); }} labelRender={({ value }) => { if (value != null) { @@ -56,10 +55,23 @@ const DeploymentProviderSelect = ({ onFilter, ...props }: DeploymentProviderSele return {props.placeholder}; }} - options={options} + options={ + showAvailability + ? [ + { + label: t("provider.text.available_group"), + options: dataSource2Options(dataSources.available), + }, + { + label: t("provider.text.unavailable_group"), + options: dataSource2Options(dataSources.unavailable), + }, + ] + : dataSource2Options(dataSources.filtered) + } optionFilterProp={void 0} optionLabelProp={void 0} - optionRender={(option) => renderOption(option.data.value)} + optionRender={(option) => renderOption(option.data.value as string)} /> ); }; diff --git a/ui/src/components/provider/NotificationProviderPicker.tsx b/ui/src/components/provider/NotificationProviderPicker.tsx index 2a6bdc45e..7a46c3885 100644 --- a/ui/src/components/provider/NotificationProviderPicker.tsx +++ b/ui/src/components/provider/NotificationProviderPicker.tsx @@ -10,7 +10,7 @@ import { mergeCls } from "@/utils/css"; import { type SharedPickerProps, usePickerDataSource, usePickerWrapperCols } from "./_shared"; export interface NotificationProviderPickerProps extends SharedPickerProps { - showAvailable?: boolean; + showAvailability?: boolean; } const NotificationProviderPicker = ({ @@ -19,8 +19,8 @@ const NotificationProviderPicker = ({ autoFocus, gap = "middle", placeholder, - showAvailable = true, - showSearch = true, + showAvailability = false, + showSearch = false, onFilter, onSelect, }: NotificationProviderPickerProps) => { @@ -38,28 +38,30 @@ const NotificationProviderPicker = ({ const dataSources = usePickerDataSource({ dataSource: Array.from(notificationProvidersMap.values()), + filters: [onFilter!], keyword: keyword, - onFilter: onFilter, }); - const renderOption = (provider: NotificationProvider) => { + const renderOption = (provider: NotificationProvider, transparent: boolean = false) => { return (
{ handleProviderTypeSelect(provider.type); }} > -
- } shape="square" size={28} /> -
-
- - {t(provider.name) || "\u00A0"} - +
+
+ } shape="square" size={28} /> +
+
+ + {t(provider.name) || "\u00A0"} + +
@@ -89,13 +91,13 @@ const NotificationProviderPicker = ({ [`gap-${+gap || "2"}`]: typeof gap === "number", })} > - {(showAvailable ? dataSources.available : dataSources.filtered).map((provider) => renderOption(provider))} + {(showAvailability ? dataSources.available : dataSources.filtered).map((provider) => renderOption(provider))}
- 0}> + 0}> - {t("provider.text.unavailable_options")} + {t("provider.text.unavailable_divider")} @@ -107,7 +109,7 @@ const NotificationProviderPicker = ({ [`gap-${+gap || "2"}`]: typeof gap === "number", })} > - {dataSources.unavailable.map((provider) => renderOption(provider))} + {dataSources.unavailable.map((provider) => renderOption(provider, true))}
diff --git a/ui/src/components/provider/NotificationProviderSelect.tsx b/ui/src/components/provider/NotificationProviderSelect.tsx index f7f86ad16..ee65f35f3 100644 --- a/ui/src/components/provider/NotificationProviderSelect.tsx +++ b/ui/src/components/provider/NotificationProviderSelect.tsx @@ -1,34 +1,31 @@ -import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Avatar, Select, Typography, theme } from "antd"; import { type NotificationProvider, notificationProvidersMap } from "@/domain/provider"; -import { type SharedSelectProps } from "./_shared"; +import { type SharedSelectProps, useSelectDataSource } from "./_shared"; -export interface NotificationProviderSelectProps extends SharedSelectProps {} +export interface NotificationProviderSelectProps extends SharedSelectProps { + showAvailability?: boolean; +} -const NotificationProviderSelect = ({ onFilter, ...props }: NotificationProviderSelectProps) => { +const NotificationProviderSelect = ({ showAvailability = false, onFilter, ...props }: NotificationProviderSelectProps) => { const { t } = useTranslation(); const { token: themeToken } = theme.useToken(); - const options = useMemo>(() => { - return Array.from(notificationProvidersMap.values()) - .filter((provider) => { - if (onFilter) { - return onFilter(provider.type, provider); - } - - return true; - }) - .map((provider) => ({ - key: provider.type, - value: provider.type, - label: t(provider.name), - data: provider, - })); - }, [onFilter]); + const dataSources = useSelectDataSource({ + dataSource: Array.from(notificationProvidersMap.values()), + filters: [onFilter!], + }); + const dataSource2Options = (providers: NotificationProvider[]): Array<{ key: string; value: string; label: string; data: NotificationProvider }> => { + return providers.map((provider) => ({ + key: provider.type, + value: provider.type, + label: t(provider.name), + data: provider, + })); + }; const renderOption = (key: string) => { const provider = notificationProvidersMap.get(key); @@ -45,9 +42,11 @@ const NotificationProviderSelect = ({ onFilter, ...props }: NotificationProvider {...props} filterOption={(inputValue, option) => { if (!option) return false; + if (!option.label) return false; + if (!option.value) return false; const value = inputValue.toLowerCase(); - return option.value.toLowerCase().includes(value) || option.label.toLowerCase().includes(value); + return String(option.value).toLowerCase().includes(value) || String(option.label).toLowerCase().includes(value); }} labelRender={({ value }) => { if (value != null) { @@ -56,10 +55,23 @@ const NotificationProviderSelect = ({ onFilter, ...props }: NotificationProvider return {props.placeholder}; }} - options={options} + options={ + showAvailability + ? [ + { + label: t("provider.text.available_group"), + options: dataSource2Options(dataSources.available), + }, + { + label: t("provider.text.unavailable_group"), + options: dataSource2Options(dataSources.unavailable), + }, + ] + : dataSource2Options(dataSources.filtered) + } optionFilterProp={void 0} optionLabelProp={void 0} - optionRender={(option) => renderOption(option.data.value)} + optionRender={(option) => renderOption(option.data.value as string)} /> ); }; diff --git a/ui/src/components/provider/_shared.ts b/ui/src/components/provider/_shared.ts index ff345c880..9f47e4c44 100644 --- a/ui/src/components/provider/_shared.ts +++ b/ui/src/components/provider/_shared.ts @@ -16,81 +16,30 @@ export interface SharedSelectProps onFilter?: (value: string, option: T) => boolean; } -export interface SharedPickerProps { - className?: string; - style?: React.CSSProperties; - autoFocus?: boolean; - gap?: number | "small" | "middle" | "large"; - placeholder?: string; - showSearch?: boolean; - onFilter?: (value: string, option: T) => boolean; - onSelect?: (value: string) => void; -} - -export const usePickerWrapperCols = (width: number) => { - const wrapperElRef = useRef(null); - const wrapperSize = useSize(wrapperElRef); - - const cols = useMemo(() => { - if (!wrapperSize) { - return 1; - } - - const cols = Math.floor(wrapperSize.width / width); - return Math.min(9, Math.max(1, cols)); - }, [wrapperSize, width]); - - return { - wrapperElRef, - cols, - }; -}; - -export const usePickerDataSource = ({ +export const useSelectDataSource = ({ dataSource, filters, - keyword, - onFilter, deps, }: { dataSource: T[]; - filters?: Array<(option: T) => boolean>; - keyword?: string; - onFilter?: (value: string, option: T) => boolean; + filters?: Array<(value: string, option: T) => boolean>; deps?: React.DependencyList; }) => { - const { t } = useTranslation(); - const { accesses, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "fetchAccesses"])); useMount(() => fetchAccesses(false)); const filteredDataSource = useMemo(() => { - return dataSource - .filter((provider) => { - if (onFilter) { - return onFilter(provider.type, provider); + return dataSource.filter((provider) => { + if (filters) { + for (const filter of filters) { + if (!filter) continue; + if (!filter(provider.type, provider)) return false; } + } - return true; - }) - .filter((provider) => { - if (filters) { - for (const filter of filters) { - if (!filter(provider)) return false; - } - } - - return true; - }) - .filter((provider) => { - if (keyword) { - const value = keyword.toLowerCase(); - return provider.type.toLowerCase().includes(value) || t(provider.name).toLowerCase().includes(value); - } - - return true; - }); - }, [dataSource, filters, keyword, onFilter, ...(deps ?? [])]); + return true; + }); + }, [dataSource, filters, ...(deps ?? [])]); const availableDataSource = useMemo(() => { return filteredDataSource.filter((provider) => { @@ -107,7 +56,94 @@ export const usePickerDataSource = ({ }, [filteredDataSource, availableDataSource, ...(deps ?? [])]); return { - all: dataSource, + raw: dataSource, + filtered: filteredDataSource, + available: availableDataSource, + unavailable: unavailableDataSource, + }; +}; + +export interface SharedPickerProps { + className?: string; + style?: React.CSSProperties; + autoFocus?: boolean; + gap?: number | "small" | "middle" | "large"; + placeholder?: string; + showSearch?: boolean; + onFilter?: (value: string, option: T) => boolean; + onSelect?: (value: string) => void; +} + +export const usePickerWrapperCols = (width: number) => { + const wrapperElRef = useRef(null); + const wrapperSize = useSize(wrapperElRef); + + const columns = useMemo(() => { + const wWidth = wrapperSize?.width ?? document.body.clientWidth - 256; + const wCols = Math.floor(wWidth / width); + return Math.min(9, Math.max(1, wCols)); + }, [wrapperSize, width]); + + return { + wrapperElRef, + cols: columns, + }; +}; + +export const usePickerDataSource = ({ + dataSource, + filters, + keyword, + deps, +}: { + dataSource: T[]; + filters?: Array<(value: string, option: T) => boolean>; + keyword?: string; + deps?: React.DependencyList; +}) => { + const { t } = useTranslation(); + + const { accesses, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "fetchAccesses"])); + useMount(() => fetchAccesses(false)); + + const filteredDataSource = useMemo(() => { + return dataSource + .filter((provider) => { + if (filters) { + for (const filter of filters) { + if (!filter) continue; + if (!filter(provider.type, provider)) return false; + } + } + + return true; + }) + .filter((provider) => { + if (keyword) { + const value = keyword.toLowerCase(); + return provider.type.toLowerCase().includes(value) || t(provider.name).toLowerCase().includes(value); + } + + return true; + }); + }, [dataSource, filters, keyword, ...(deps ?? [])]); + + const availableDataSource = useMemo(() => { + return filteredDataSource.filter((provider) => { + return accesses.some((access) => { + if ("builtin" in provider && provider.builtin) return true; + if ("provider" in provider) return access.provider === provider.provider; + return access.provider === provider.type; + }); + }); + }, [accesses, filteredDataSource, ...(deps ?? [])]); + + const unavailableDataSource = useMemo(() => { + return filteredDataSource.filter((item) => !availableDataSource.includes(item)); + }, [filteredDataSource, availableDataSource, ...(deps ?? [])]); + + return { + raw: dataSource, filtered: filteredDataSource, available: availableDataSource, unavailable: unavailableDataSource, diff --git a/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx index 423d96f64..888bf7635 100644 --- a/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx +++ b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx @@ -4,7 +4,22 @@ import { Link } from "react-router"; import { type FlowNodeEntity, getNodeForm } from "@flowgram.ai/fixed-layout-editor"; import { IconChevronRight, IconCircleMinus, IconPlus } from "@tabler/icons-react"; import { useControllableValue, useMount } from "ahooks"; -import { type AnchorProps, AutoComplete, Button, Divider, Flex, Form, type FormInstance, Input, InputNumber, Select, Space, Switch, Typography } from "antd"; +import { + type AnchorProps, + AutoComplete, + Button, + Divider, + Flex, + Form, + type FormInstance, + Input, + InputNumber, + Radio, + Select, + Space, + Switch, + Typography, +} from "antd"; import { createSchemaFieldRule } from "antd-zod"; import { z } from "zod"; @@ -31,6 +46,9 @@ import { NodeType } from "../nodes/typings"; const MULTIPLE_INPUT_SEPARATOR = ";"; +const CHALLENGE_TYPE_DNS01 = "dns-01"; +const CHALLENGE_TYPE_HTTP01 = "http-01"; + export interface BizApplyNodeConfigFormProps { form: FormInstance; node: FlowNodeEntity; @@ -51,12 +69,13 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps) const formSchema = getSchema({ i18n }); const formRule = createSchemaFieldRule(formSchema); - const { form: formInst, formProps } = useAntdForm({ + const { form: formInst, formProps } = useAntdForm>({ form: props.form, name: "workflowNodeBizApplyConfigForm", initialValues: initialValues ?? getInitialValues(), }); + const fieldChallengeType = Form.useWatch("challengeType", { form: formInst, preserve: true }); const fieldProvider = Form.useWatch("provider", { form: formInst, preserve: true }); const fieldProviderAccessId = Form.useWatch("providerAccessId", { form: formInst, preserve: true }); const fieldCAProvider = Form.useWatch("caProvider", { form: formInst, preserve: true }); @@ -112,6 +131,29 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps) } }, [fieldCAProvider]); + const handleChallengeTypeChange = (value: string) => { + switch (value) { + case CHALLENGE_TYPE_DNS01: + { + formInst.setFieldValue("provider", void 0); + formInst.setFieldValue("providerAccessId", void 0); + formInst.setFieldValue("providerConfig", void 0); + } + break; + + case CHALLENGE_TYPE_HTTP01: + { + formInst.setFieldValue("provider", void 0); + formInst.setFieldValue("providerAccessId", void 0); + formInst.setFieldValue("providerConfig", void 0); + formInst.setFieldValue("dnsPropagationWait", void 0); + formInst.setFieldValue("dnsPropagationTimeout", void 0); + formInst.setFieldValue("dnsTTL", void 0); + } + break; + } + }; + const handleProviderSelect = (value: string) => { if (fieldProvider === value) return; @@ -136,7 +178,7 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps) const handleCAProviderSelect = (value?: string | undefined) => { // 切换 CA 提供商时联动授权信息 - if (value === "") { + if (value == null || value === "") { setTimeout(() => { formInst.setFieldValue("caProvider", void 0); formInst.setFieldValue("caProviderAccessId", void 0); @@ -173,14 +215,11 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps) - +
+ +
+ + + {t("workflow_node.deploy.form_anchor.deployment.title")} + + + - - { + if (!option) return false; + if (!option.label) return false; + if (!option.value) return false; + + const value = inputValue.toLowerCase(); + return String(option.value).toLowerCase().includes(value) || String(option.label).toLowerCase().includes(value); + }} + labelRender={({ value }) => { + if (value != null) { + return renderOption(value as string); + } + + return {props.placeholder}; + }} + options={options} + optionFilterProp={void 0} + optionLabelProp={void 0} + optionRender={(option) => renderOption(option.data.value as string)} + /> + ); +}; + +export default ACMEHttp01ProviderSelect; diff --git a/ui/src/components/provider/DeploymentProviderSelect.tsx b/ui/src/components/provider/DeploymentProviderSelect.tsx index 285e0e690..d3bea4aa2 100644 --- a/ui/src/components/provider/DeploymentProviderSelect.tsx +++ b/ui/src/components/provider/DeploymentProviderSelect.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Avatar, Select, Typography, theme } from "antd"; @@ -18,14 +19,29 @@ const DeploymentProviderSelect = ({ showAvailability = false, onFilter, ...props dataSource: Array.from(deploymentProvidersMap.values()), filters: [onFilter!], }); - const dataSource2Options = (providers: DeploymentProvider[]): Array<{ key: string; value: string; label: string; data: DeploymentProvider }> => { - return providers.map((provider) => ({ - key: provider.type, - value: provider.type, - label: t(provider.name), - data: provider, - })); - }; + const options = useMemo(() => { + const convert = (providers: DeploymentProvider[]): Array<{ key: string; value: string; label: string; data: DeploymentProvider }> => { + return providers.map((provider) => ({ + key: provider.type, + value: provider.type, + label: t(provider.name), + data: provider, + })); + }; + + return showAvailability + ? [ + { + label: t("provider.text.available_group"), + options: convert(dataSources.available), + }, + { + label: t("provider.text.unavailable_group"), + options: convert(dataSources.unavailable), + }, + ].filter((group) => group.options.length > 0) + : convert(dataSources.filtered); + }, [showAvailability, dataSources]); const renderOption = (key: string) => { const provider = deploymentProvidersMap.get(key); @@ -55,20 +71,7 @@ const DeploymentProviderSelect = ({ showAvailability = false, onFilter, ...props return {props.placeholder}; }} - options={ - showAvailability - ? [ - { - label: t("provider.text.available_group"), - options: dataSource2Options(dataSources.available), - }, - { - label: t("provider.text.unavailable_group"), - options: dataSource2Options(dataSources.unavailable), - }, - ] - : dataSource2Options(dataSources.filtered) - } + options={options} optionFilterProp={void 0} optionLabelProp={void 0} optionRender={(option) => renderOption(option.data.value as string)} diff --git a/ui/src/components/provider/NotificationProviderSelect.tsx b/ui/src/components/provider/NotificationProviderSelect.tsx index ee65f35f3..8a8a0c715 100644 --- a/ui/src/components/provider/NotificationProviderSelect.tsx +++ b/ui/src/components/provider/NotificationProviderSelect.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Avatar, Select, Typography, theme } from "antd"; @@ -18,14 +19,29 @@ const NotificationProviderSelect = ({ showAvailability = false, onFilter, ...pro dataSource: Array.from(notificationProvidersMap.values()), filters: [onFilter!], }); - const dataSource2Options = (providers: NotificationProvider[]): Array<{ key: string; value: string; label: string; data: NotificationProvider }> => { - return providers.map((provider) => ({ - key: provider.type, - value: provider.type, - label: t(provider.name), - data: provider, - })); - }; + const options = useMemo(() => { + const convert = (providers: NotificationProvider[]): Array<{ key: string; value: string; label: string; data: NotificationProvider }> => { + return providers.map((provider) => ({ + key: provider.type, + value: provider.type, + label: t(provider.name), + data: provider, + })); + }; + + return showAvailability + ? [ + { + label: t("provider.text.available_group"), + options: convert(dataSources.available), + }, + { + label: t("provider.text.unavailable_group"), + options: convert(dataSources.unavailable), + }, + ].filter((group) => group.options.length > 0) + : convert(dataSources.filtered); + }, [showAvailability, dataSources]); const renderOption = (key: string) => { const provider = notificationProvidersMap.get(key); @@ -55,20 +71,7 @@ const NotificationProviderSelect = ({ showAvailability = false, onFilter, ...pro return {props.placeholder}; }} - options={ - showAvailability - ? [ - { - label: t("provider.text.available_group"), - options: dataSource2Options(dataSources.available), - }, - { - label: t("provider.text.unavailable_group"), - options: dataSource2Options(dataSources.unavailable), - }, - ] - : dataSource2Options(dataSources.filtered) - } + options={options} optionFilterProp={void 0} optionLabelProp={void 0} optionRender={(option) => renderOption(option.data.value as string)} diff --git a/ui/src/components/workflow/designer/forms/BizApplyNodeConfigFieldsProviderLocal.tsx b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigFieldsProviderLocal.tsx new file mode 100644 index 000000000..c30b50cf3 --- /dev/null +++ b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigFieldsProviderLocal.tsx @@ -0,0 +1,52 @@ +import { getI18n, useTranslation } from "react-i18next"; +import { Form, Input } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import { z } from "zod"; + +import { useFormNestedFieldsContext } from "./_context"; + +const BizApplyNodeConfigFieldsProviderLocal = () => { + const { i18n, t } = useTranslation(); + + const { parentNamePath } = useFormNestedFieldsContext(); + const formSchema = z.object({ + [parentNamePath]: getSchema({ i18n }), + }); + const formRule = createSchemaFieldRule(formSchema); + const initialValues = getInitialValues(); + + return ( + <> + } + > + + + + ); +}; + +const getInitialValues = (): Nullish>> => { + return { + webRootPath: "/var/www/html/", + }; +}; + +const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { + const { t } = i18n; + + return z.object({ + webRootPath: z.string().nonempty(t("workflow_node.apply.form.local_webroot_path.placeholder")), + }); +}; + +const _default = Object.assign(BizApplyNodeConfigFieldsProviderLocal, { + getInitialValues, + getSchema, +}); + +export default _default; diff --git a/ui/src/components/workflow/designer/forms/BizApplyNodeConfigFieldsProviderSSH.tsx b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigFieldsProviderSSH.tsx new file mode 100644 index 000000000..cad4fa024 --- /dev/null +++ b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigFieldsProviderSSH.tsx @@ -0,0 +1,52 @@ +import { getI18n, useTranslation } from "react-i18next"; +import { Form, Input } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import { z } from "zod"; + +import { useFormNestedFieldsContext } from "./_context"; + +const BizApplyNodeConfigFieldsProviderSSH = () => { + const { i18n, t } = useTranslation(); + + const { parentNamePath } = useFormNestedFieldsContext(); + const formSchema = z.object({ + [parentNamePath]: getSchema({ i18n }), + }); + const formRule = createSchemaFieldRule(formSchema); + const initialValues = getInitialValues(); + + return ( + <> + } + > + + + + ); +}; + +const getInitialValues = (): Nullish>> => { + return { + webRootPath: "/var/www/html/", + }; +}; + +const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { + const { t } = i18n; + + return z.object({ + webRootPath: z.string().nonempty(t("workflow_node.apply.form.ssh_webroot_path.placeholder")), + }); +}; + +const _default = Object.assign(BizApplyNodeConfigFieldsProviderSSH, { + getInitialValues, + getSchema, +}); + +export default _default; diff --git a/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx index 888bf7635..5ab9cb563 100644 --- a/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx +++ b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx @@ -27,9 +27,11 @@ import AccessEditDrawer from "@/components/access/AccessEditDrawer"; import AccessSelect from "@/components/access/AccessSelect"; import MultipleSplitValueInput from "@/components/MultipleSplitValueInput"; import ACMEDns01ProviderSelect from "@/components/provider/ACMEDns01ProviderSelect"; +import ACMEHttp01ProviderSelect from "@/components/provider/ACMEHttp01ProviderSelect"; import CAProviderSelect from "@/components/provider/CAProviderSelect"; import Show from "@/components/Show"; -import { ACCESS_USAGES, ACME_DNS01_PROVIDERS, accessProvidersMap, acmeDns01ProvidersMap, caProvidersMap } from "@/domain/provider"; +import { type AccessModel } from "@/domain/access"; +import { ACME_DNS01_PROVIDERS, ACME_HTTP01_PROVIDERS, acmeDns01ProvidersMap, acmeHttp01ProvidersMap, caProvidersMap } from "@/domain/provider"; import { type WorkflowNodeConfigForBizApply, defaultNodeConfigForBizApply } from "@/domain/workflow"; import { useAntdForm, useZustandShallowSelector } from "@/hooks"; import { useAccessesStore } from "@/stores/access"; @@ -41,6 +43,8 @@ import BizApplyNodeConfigFieldsProviderAliyunESA from "./BizApplyNodeConfigField import BizApplyNodeConfigFieldsProviderAWSRoute53 from "./BizApplyNodeConfigFieldsProviderAWSRoute53"; import BizApplyNodeConfigFieldsProviderHuaweiCloudDNS from "./BizApplyNodeConfigFieldsProviderHuaweiCloudDNS"; import BizApplyNodeConfigFieldsProviderJDCloudDNS from "./BizApplyNodeConfigFieldsProviderJDCloudDNS"; +import BizApplyNodeConfigFieldsProviderLocal from "./BizApplyNodeConfigFieldsProviderLocal"; +import BizApplyNodeConfigFieldsProviderSSH from "./BizApplyNodeConfigFieldsProviderSSH"; import BizApplyNodeConfigFieldsProviderTencentCloudEO from "./BizApplyNodeConfigFieldsProviderTencentCloudEO"; import { NodeType } from "../nodes/typings"; @@ -62,6 +66,16 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps) const { i18n, t } = useTranslation(); const { accesses } = useAccessesStore(useZustandShallowSelector("accesses")); + const accessOptionFilter = (_: string, option: AccessModel) => { + if (option.reserve) return false; + if (fieldChallengeType === CHALLENGE_TYPE_DNS01) return acmeDns01ProvidersMap.get(fieldProvider)?.provider === option.provider; + if (fieldChallengeType === CHALLENGE_TYPE_HTTP01) return acmeHttp01ProvidersMap.get(fieldProvider)?.provider === option.provider; + return false; + }; + const accessOptionFilterForCA = (_: string, option: AccessModel) => { + if (option.reserve !== "ca") return false; + return caProvidersMap.get(fieldCAProvider)?.provider === option.provider; + }; const initialValues = useMemo(() => { return getNodeForm(node)?.getValueIn("config") as WorkflowNodeConfigForBizApply | undefined; @@ -79,46 +93,85 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps) const fieldProvider = Form.useWatch("provider", { form: formInst, preserve: true }); const fieldProviderAccessId = Form.useWatch("providerAccessId", { form: formInst, preserve: true }); const fieldCAProvider = Form.useWatch("caProvider", { form: formInst, preserve: true }); + const fieldCAProviderAccessId = Form.useWatch("caProviderAccessId", { form: formInst, preserve: true }); const NestedProviderConfigFields = useMemo(() => { /* 注意:如果追加新的子组件,请保持以 ASCII 排序。 NOTICE: If you add new child component, please keep ASCII order. */ - switch (fieldProvider) { - case ACME_DNS01_PROVIDERS.ALIYUN_ESA: { - return BizApplyNodeConfigFieldsProviderAliyunESA; - } - case ACME_DNS01_PROVIDERS.AWS: - case ACME_DNS01_PROVIDERS.AWS_ROUTE53: { - return BizApplyNodeConfigFieldsProviderAWSRoute53; - } - case ACME_DNS01_PROVIDERS.HUAWEICLOUD: - case ACME_DNS01_PROVIDERS.HUAWEICLOUD_DNS: { - return BizApplyNodeConfigFieldsProviderHuaweiCloudDNS; - } - case ACME_DNS01_PROVIDERS.JDCLOUD: - case ACME_DNS01_PROVIDERS.JDCLOUD_DNS: { - return BizApplyNodeConfigFieldsProviderJDCloudDNS; - } - case ACME_DNS01_PROVIDERS.TENCENTCLOUD_EO: { - return BizApplyNodeConfigFieldsProviderTencentCloudEO; - } - } - }, [fieldProvider]); + switch (fieldChallengeType) { + case CHALLENGE_TYPE_DNS01: + { + switch (fieldProvider) { + case ACME_DNS01_PROVIDERS.ALIYUN_ESA: { + return BizApplyNodeConfigFieldsProviderAliyunESA; + } + case ACME_DNS01_PROVIDERS.AWS: + case ACME_DNS01_PROVIDERS.AWS_ROUTE53: { + return BizApplyNodeConfigFieldsProviderAWSRoute53; + } + case ACME_DNS01_PROVIDERS.HUAWEICLOUD: + case ACME_DNS01_PROVIDERS.HUAWEICLOUD_DNS: { + return BizApplyNodeConfigFieldsProviderHuaweiCloudDNS; + } + case ACME_DNS01_PROVIDERS.JDCLOUD: + case ACME_DNS01_PROVIDERS.JDCLOUD_DNS: { + return BizApplyNodeConfigFieldsProviderJDCloudDNS; + } + case ACME_DNS01_PROVIDERS.TENCENTCLOUD_EO: { + return BizApplyNodeConfigFieldsProviderTencentCloudEO; + } + } + } + break; - const [showProvider, setShowProvider] = useState(false); - useEffect(() => { - // 通常情况下每个授权信息只对应一个 DNS 提供商,此时无需显示 DNS 提供商字段; - // 如果对应多个(如 AWS 的 Route53、Lightsail,阿里云的 DNS、ESA,腾讯云的 DNS、EdgeOne 等),则显示。 - if (fieldProviderAccessId) { - const access = accesses.find((e) => e.id === fieldProviderAccessId); - const providers = Array.from(acmeDns01ProvidersMap.values()).filter((e) => e.provider === access?.provider); - setShowProvider(providers.length > 1); - } else { - setShowProvider(false); + case CHALLENGE_TYPE_HTTP01: + switch (fieldProvider) { + case ACME_HTTP01_PROVIDERS.LOCAL: { + return BizApplyNodeConfigFieldsProviderLocal; + } + case ACME_HTTP01_PROVIDERS.SSH: { + return BizApplyNodeConfigFieldsProviderSSH; + } + } + break; } - }, [accesses, fieldProviderAccessId]); + }, [fieldChallengeType, fieldProvider]); + + const [showProviderAccess, setShowProviderAccess] = useState(false); + useEffect(() => { + // 内置的质询提供商(如本地主机)无需显示授权信息字段 + switch (fieldChallengeType) { + case CHALLENGE_TYPE_DNS01: + { + if (fieldProvider) { + const provider = acmeDns01ProvidersMap.get(fieldProvider); + setShowProviderAccess(!provider?.builtin); + } else { + setShowProviderAccess(false); + } + } + break; + + case CHALLENGE_TYPE_HTTP01: + { + if (fieldProvider) { + const provider = acmeHttp01ProvidersMap.get(fieldProvider); + setShowProviderAccess(!provider?.builtin); + } else { + setShowProviderAccess(false); + } + } + break; + + default: + { + setShowProviderAccess(false); + } + break; + } + }, [fieldChallengeType, fieldProvider]); const [showCAProviderAccess, setShowCAProviderAccess] = useState(false); useEffect(() => { @@ -131,7 +184,55 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps) } }, [fieldCAProvider]); + useEffect(() => { + // 如果未选择质询提供商,则清空授权信息 + if (!fieldProvider && fieldProviderAccessId) { + formInst.setFieldValue("providerAccessId", void 0); + return; + } + + // 如果已选择质询提供商只有一个授权信息,则自动选择该授权信息 + if (fieldProvider && !fieldProviderAccessId) { + const availableAccesses = accesses + .filter((access) => accessOptionFilter(access.provider, access)) + .filter((access) => { + if (fieldChallengeType === CHALLENGE_TYPE_DNS01) return acmeDns01ProvidersMap.get(fieldProvider)?.provider === access.provider; + if (fieldChallengeType === CHALLENGE_TYPE_HTTP01) return acmeHttp01ProvidersMap.get(fieldProvider)?.provider === access.provider; + return false; + }); + if (availableAccesses.length === 1) { + formInst.setFieldValue("providerAccessId", availableAccesses[0].id); + } + } + }, [fieldChallengeType, fieldProvider, fieldProviderAccessId]); + + useEffect(() => { + // 如果未选择 CA 提供商,则清空授权信息 + if (!fieldCAProvider && fieldCAProviderAccessId) { + formInst.setFieldValue("caProviderAccessId", void 0); + return; + } + + // 如果已选择 CA 提供商只有一个授权信息,则自动选择该授权信息 + if (fieldCAProvider && !fieldCAProviderAccessId) { + const availableAccesses = accesses + .filter((access) => accessOptionFilterForCA(access.provider, access)) + .filter((access) => caProvidersMap.get(fieldCAProvider)?.provider === access.provider); + if (availableAccesses.length === 1) { + formInst.setFieldValue("caProviderAccessId", availableAccesses[0].id); + } + } + }, [fieldCAProvider, fieldCAProviderAccessId]); + const handleChallengeTypeChange = (value: string) => { + const resetFieldIfInvalid = (field: keyof z.infer) => { + const fieldSchame = formSchema.pick({ [field]: true }); + const fieldValue = formInst.getFieldValue(field); + if (!fieldSchame.safeParse({ [field]: fieldValue }).success) { + formInst.setFieldValue(field, void 0); + } + }; + switch (value) { case CHALLENGE_TYPE_DNS01: { @@ -146,33 +247,23 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps) formInst.setFieldValue("provider", void 0); formInst.setFieldValue("providerAccessId", void 0); formInst.setFieldValue("providerConfig", void 0); - formInst.setFieldValue("dnsPropagationWait", void 0); - formInst.setFieldValue("dnsPropagationTimeout", void 0); - formInst.setFieldValue("dnsTTL", void 0); + + resetFieldIfInvalid("dnsPropagationWait"); + resetFieldIfInvalid("dnsPropagationTimeout"); + resetFieldIfInvalid("dnsTTL"); } break; } }; - const handleProviderSelect = (value: string) => { - if (fieldProvider === value) return; - - // 切换 DNS 提供商时联动授权信息 + const handleProviderSelect = (value?: string | undefined) => { + // 切换质询提供商时重置表单,避免其他提供商的配置字段影响当前提供商 if (initialValues?.provider === value) { - formInst.setFieldValue("providerAccessId", initialValues?.providerAccessId); + formInst.setFieldValue("providerAccessId", void 0); + formInst.resetFields(["providerConfig"]); } else { - if (acmeDns01ProvidersMap.get(fieldProvider)?.provider !== acmeDns01ProvidersMap.get(value)?.provider) { - formInst.setFieldValue("providerAccessId", void 0); - } - } - }; - - const handleProviderAccessSelect = (value: string) => { - // 切换授权信息时联动 DNS 提供商 - const access = accesses.find((access) => access.id === value); - const provider = Array.from(acmeDns01ProvidersMap.values()).find((provider) => provider.provider === access?.provider); - if (fieldProvider !== provider?.type) { - formInst.setFieldValue("provider", provider?.type); + formInst.setFieldValue("providerAccessId", void 0); + formInst.setFieldValue("providerConfig", void 0); } }; @@ -196,7 +287,21 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps)
- + + } + rules={[formRule]} + > +
- +
+ + + {t("workflow_node.apply.form_anchor.challenge.title")} + + + + } + > handleChallengeTypeChange(e.target.value)}> DNS-01 HTTP-01 -