diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..eb647dc3 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: ["https://profile.ikit.fun/sponsors/"] diff --git a/internal/certificate/service.go b/internal/certificate/service.go index 692c2f89..b8f5fa89 100644 --- a/internal/certificate/service.go +++ b/internal/certificate/service.go @@ -5,9 +5,12 @@ import ( "bytes" "context" "encoding/json" + "errors" "strconv" "strings" + "time" + "github.com/go-acme/lego/v4/certcrypto" "github.com/usual2970/certimate/internal/app" "github.com/usual2970/certimate/internal/domain" "github.com/usual2970/certimate/internal/domain/dtos" @@ -176,6 +179,26 @@ func (s *CertificateService) ArchiveFile(ctx context.Context, req *dtos.Certific } } +func (s *CertificateService) ValidateCertificate(ctx context.Context, req *dtos.CertificateValidateCertificateReq) (*dtos.CertificateValidateCertificateResp, error) { + info, err := certs.ParseCertificateFromPEM(req.Certificate) + if err != nil { + return nil, err + } + + if time.Now().After(info.NotAfter) { + return nil, errors.New("证书已过期") + } + + return &dtos.CertificateValidateCertificateResp{ + Domains: strings.Join(info.DNSNames, ";"), + }, nil +} + +func (s *CertificateService) ValidatePrivateKey(ctx context.Context, req *dtos.CertificateValidatePrivateKeyReq) error { + _, err := certcrypto.ParsePEMPrivateKey([]byte(req.PrivateKey)) + return err +} + func buildExpireSoonNotification(certificates []*domain.Certificate) *struct { Subject string Message string diff --git a/internal/domain/dtos/certificate.go b/internal/domain/dtos/certificate.go index 3fe13f16..8c9e80ae 100644 --- a/internal/domain/dtos/certificate.go +++ b/internal/domain/dtos/certificate.go @@ -4,3 +4,28 @@ type CertificateArchiveFileReq struct { CertificateId string `json:"-"` Format string `json:"format"` } + +type CertificateArchiveFileResp struct { + Certificate string `json:"certificate"` + PrivateKey string `json:"privateKey"` +} + +type CertificateValidateCertificateReq struct { + Certificate string `json:"certificate"` +} + +type CertificateValidateCertificateResp struct { + Domains string `json:"domains"` +} + +type CertificateValidatePrivateKeyReq struct { + PrivateKey string `json:"privateKey"` +} + +type CertificateUploadReq struct { + WorkflowId string `json:"workflowId"` + WorkflowNodeId string `json:"workflowNodeId"` + CertificateId string `json:"certificateId"` + Certificate string `json:"certificate"` + PrivateKey string `json:"privateKey"` +} diff --git a/internal/domain/workflow.go b/internal/domain/workflow.go index 10ca1c28..ac8fbce6 100644 --- a/internal/domain/workflow.go +++ b/internal/domain/workflow.go @@ -29,6 +29,7 @@ const ( WorkflowNodeTypeStart = WorkflowNodeType("start") WorkflowNodeTypeEnd = WorkflowNodeType("end") WorkflowNodeTypeApply = WorkflowNodeType("apply") + WorkflowNodeTypeUpload = WorkflowNodeType("upload") WorkflowNodeTypeDeploy = WorkflowNodeType("deploy") WorkflowNodeTypeNotify = WorkflowNodeType("notify") WorkflowNodeTypeBranch = WorkflowNodeType("branch") @@ -75,6 +76,12 @@ type WorkflowNodeConfigForApply struct { SkipBeforeExpiryDays int32 `json:"skipBeforeExpiryDays"` // 证书到期前多少天前跳过续期(零值将使用默认值 30) } +type WorkflowNodeConfigForUpload struct { + Certificate string `json:"certificate"` + PrivateKey string `json:"privateKey"` + Domains string `json:"domains"` +} + type WorkflowNodeConfigForDeploy struct { Certificate string `json:"certificate"` // 前序节点输出的证书,形如“${NodeId}#certificate” Provider string `json:"provider"` // 主机提供商 @@ -133,6 +140,14 @@ func (n *WorkflowNode) GetConfigForApply() WorkflowNodeConfigForApply { } } +func (n *WorkflowNode) GetConfigForUpload() WorkflowNodeConfigForUpload { + return WorkflowNodeConfigForUpload{ + Certificate: n.getConfigValueAsString("certificate"), + PrivateKey: n.getConfigValueAsString("privateKey"), + Domains: n.getConfigValueAsString("domains"), + } +} + func (n *WorkflowNode) GetConfigForDeploy() WorkflowNodeConfigForDeploy { return WorkflowNodeConfigForDeploy{ Certificate: n.getConfigValueAsString("certificate"), diff --git a/internal/rest/handlers/certificate.go b/internal/rest/handlers/certificate.go index 5f240bb0..62c4df53 100644 --- a/internal/rest/handlers/certificate.go +++ b/internal/rest/handlers/certificate.go @@ -12,6 +12,8 @@ import ( type certificateService interface { ArchiveFile(ctx context.Context, req *dtos.CertificateArchiveFileReq) ([]byte, error) + ValidateCertificate(ctx context.Context, req *dtos.CertificateValidateCertificateReq) (*dtos.CertificateValidateCertificateResp, error) + ValidatePrivateKey(ctx context.Context, req *dtos.CertificateValidatePrivateKeyReq) error } type CertificateHandler struct { @@ -24,10 +26,12 @@ func NewCertificateHandler(router *router.RouterGroup[*core.RequestEvent], servi } group := router.Group("/certificates") - group.POST("/{certificateId}/archive", handler.run) + group.POST("/{certificateId}/archive", handler.archive) + group.POST("/validate/certificate", handler.validateCertificate) + group.POST("/validate/private-key", handler.validatePrivateKey) } -func (handler *CertificateHandler) run(e *core.RequestEvent) error { +func (handler *CertificateHandler) archive(e *core.RequestEvent) error { req := &dtos.CertificateArchiveFileReq{} req.CertificateId = e.Request.PathValue("certificateId") if err := e.BindBody(req); err != nil { @@ -40,3 +44,27 @@ func (handler *CertificateHandler) run(e *core.RequestEvent) error { return resp.Ok(e, bt) } } + +func (handler *CertificateHandler) validateCertificate(e *core.RequestEvent) error { + req := &dtos.CertificateValidateCertificateReq{} + if err := e.BindBody(req); err != nil { + return resp.Err(e, err) + } + if rs, err := handler.service.ValidateCertificate(e.Request.Context(), req); err != nil { + return resp.Err(e, err) + } else { + return resp.Ok(e, rs) + } +} + +func (handler *CertificateHandler) validatePrivateKey(e *core.RequestEvent) error { + req := &dtos.CertificateValidatePrivateKeyReq{} + if err := e.BindBody(req); err != nil { + return resp.Err(e, err) + } + if err := handler.service.ValidatePrivateKey(e.Request.Context(), req); err != nil { + return resp.Err(e, err) + } else { + return resp.Ok(e, nil) + } +} diff --git a/internal/workflow/node-processor/processor.go b/internal/workflow/node-processor/processor.go index 3c347815..55e8477a 100644 --- a/internal/workflow/node-processor/processor.go +++ b/internal/workflow/node-processor/processor.go @@ -66,6 +66,8 @@ func GetProcessor(node *domain.WorkflowNode) (NodeProcessor, error) { return NewConditionNode(node), nil case domain.WorkflowNodeTypeApply: return NewApplyNode(node), nil + case domain.WorkflowNodeTypeUpload: + return NewUploadNode(node), nil case domain.WorkflowNodeTypeDeploy: return NewDeployNode(node), nil case domain.WorkflowNodeTypeNotify: diff --git a/internal/workflow/node-processor/upload_node.go b/internal/workflow/node-processor/upload_node.go new file mode 100644 index 00000000..8aa0bba7 --- /dev/null +++ b/internal/workflow/node-processor/upload_node.go @@ -0,0 +1,101 @@ +package nodeprocessor + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/usual2970/certimate/internal/domain" + "github.com/usual2970/certimate/internal/pkg/utils/certs" + "github.com/usual2970/certimate/internal/repository" +) + +type uploadNode struct { + node *domain.WorkflowNode + outputRepo workflowOutputRepository + *nodeLogger +} + +func NewUploadNode(node *domain.WorkflowNode) *uploadNode { + return &uploadNode{ + node: node, + nodeLogger: NewNodeLogger(node), + outputRepo: repository.NewWorkflowOutputRepository(), + } +} + +// Run 上传证书节点执行 +// 包含上传证书的工作流,理论上应该手动执行,如果每天定时执行,也只是重新保存一下 +func (n *uploadNode) Run(ctx context.Context) error { + n.AddOutput(ctx, + n.node.Name, + "进入上传证书节点", + ) + + config := n.node.GetConfigForUpload() + + // 检查证书是否过期 + // 如果证书过期,则直接返回错误 + certX509, err := certs.ParseCertificateFromPEM(config.Certificate) + if err != nil { + n.AddOutput(ctx, + n.node.Name, + "解析证书失败", + ) + return err + } + + if time.Now().After(certX509.NotAfter) { + n.AddOutput(ctx, + n.node.Name, + "证书已过期", + ) + return errors.New("certificate is expired") + } + + certificate := &domain.Certificate{ + Source: domain.CertificateSourceTypeUpload, + SubjectAltNames: strings.Join(certX509.DNSNames, ";"), + Certificate: config.Certificate, + PrivateKey: config.PrivateKey, + + EffectAt: certX509.NotBefore, + ExpireAt: certX509.NotAfter, + WorkflowId: getContextWorkflowId(ctx), + WorkflowNodeId: n.node.Id, + } + + // 保存执行结果 + // TODO: 先保持一个节点始终只有一个输出,后续增加版本控制 + currentOutput := &domain.WorkflowOutput{ + WorkflowId: getContextWorkflowId(ctx), + NodeId: n.node.Id, + Node: n.node, + Succeeded: true, + Outputs: n.node.Outputs, + } + + // 查询上次执行结果 + lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id) + if err != nil && !domain.IsRecordNotFoundError(err) { + n.AddOutput(ctx, n.node.Name, "查询上传记录失败", err.Error()) + return err + } + if lastOutput != nil { + currentOutput.Id = lastOutput.Id + } + if err := n.outputRepo.Save(ctx, currentOutput, certificate, func(id string) error { + if certificate != nil { + certificate.WorkflowOutputId = id + } + + return nil + }); err != nil { + n.AddOutput(ctx, n.node.Name, "保存上传记录失败", err.Error()) + return err + } + n.AddOutput(ctx, n.node.Name, "保存上传记录成功") + + return nil +} diff --git a/ui/src/api/certificates.ts b/ui/src/api/certificates.ts index 71ef30aa..3c00fdcf 100644 --- a/ui/src/api/certificates.ts +++ b/ui/src/api/certificates.ts @@ -22,3 +22,45 @@ export const archive = async (certificateId: string, format?: CertificateFormatT return resp; }; + +type ValidateCertificateResp = { + domains: string; +}; + +export const validateCertificate = async (certificate: string) => { + const pb = getPocketBase(); + const resp = await pb.send>(`/api/certificates/validate/certificate`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: { + certificate: certificate, + }, + }); + + if (resp.code != 0) { + throw new ClientResponseError({ status: resp.code, response: resp, data: {} }); + } + + return resp; +}; + +export const validatePrivateKey = async (privateKey: string) => { + const pb = getPocketBase(); + const resp = await pb.send(`/api/certificates/validate/private-key`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: { + privateKey: privateKey, + }, + }); + + if (resp.code != 0) { + throw new ClientResponseError({ status: resp.code, response: resp, data: {} }); + } + + return resp; +}; diff --git a/ui/src/components/workflow/WorkflowElement.tsx b/ui/src/components/workflow/WorkflowElement.tsx index 289689dd..d2272ef0 100644 --- a/ui/src/components/workflow/WorkflowElement.tsx +++ b/ui/src/components/workflow/WorkflowElement.tsx @@ -11,6 +11,7 @@ import ExecuteResultBranchNode from "./node/ExecuteResultBranchNode"; import ExecuteResultNode from "./node/ExecuteResultNode"; import NotifyNode from "./node/NotifyNode"; import StartNode from "./node/StartNode"; +import UploadNode from "./node/UploadNode"; export type WorkflowElementProps = { node: WorkflowNode; @@ -28,6 +29,9 @@ const WorkflowElement = ({ node, disabled, branchId, branchIndex }: WorkflowElem case WorkflowNodeType.Apply: return ; + case WorkflowNodeType.Upload: + return ; + case WorkflowNodeType.Deploy: return ; @@ -60,3 +64,4 @@ const WorkflowElement = ({ node, disabled, branchId, branchIndex }: WorkflowElem }; export default memo(WorkflowElement); + diff --git a/ui/src/components/workflow/node/AddNode.tsx b/ui/src/components/workflow/node/AddNode.tsx index 0c1d07b8..fb697e19 100644 --- a/ui/src/components/workflow/node/AddNode.tsx +++ b/ui/src/components/workflow/node/AddNode.tsx @@ -2,6 +2,7 @@ import { memo, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { CloudUploadOutlined as CloudUploadOutlinedIcon, + DeploymentUnitOutlined as DeploymentUnitOutlinedIcon, PlusOutlined as PlusOutlinedIcon, SendOutlined as SendOutlinedIcon, SisternodeOutlined as SisternodeOutlinedIcon, @@ -25,7 +26,8 @@ const AddNode = ({ node, disabled }: AddNodeProps) => { const dropdownMenus = useMemo(() => { return [ [WorkflowNodeType.Apply, "workflow_node.apply.label", ], - [WorkflowNodeType.Deploy, "workflow_node.deploy.label", ], + [WorkflowNodeType.Upload, "workflow_node.upload.label", ], + [WorkflowNodeType.Deploy, "workflow_node.deploy.label", ], [WorkflowNodeType.Notify, "workflow_node.notify.label", ], [WorkflowNodeType.Branch, "workflow_node.branch.label", ], [WorkflowNodeType.ExecuteResultBranch, "workflow_node.execute_result_branch.label", ], diff --git a/ui/src/components/workflow/node/UploadNode.tsx b/ui/src/components/workflow/node/UploadNode.tsx new file mode 100644 index 00000000..3d0e20c3 --- /dev/null +++ b/ui/src/components/workflow/node/UploadNode.tsx @@ -0,0 +1,91 @@ +import { memo, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Flex, Typography } from "antd"; +import { produce } from "immer"; + +import type { WorkflowNodeConfigForUpload } from "@/domain/workflow"; +import { WorkflowNodeType } from "@/domain/workflow"; +import { useZustandShallowSelector } from "@/hooks"; +import { useWorkflowStore } from "@/stores/workflow"; + +import UploadNodeConfigForm, { type UploadNodeConfigFormInstance } from "./UploadNodeConfigForm"; +import SharedNode, { type SharedNodeProps } from "./_SharedNode"; + +export type UploadNodeProps = SharedNodeProps; + +const UploadNode = ({ node, disabled }: UploadNodeProps) => { + if (node.type !== WorkflowNodeType.Upload) { + console.warn(`[certimate] current workflow node type is not: ${WorkflowNodeType.Upload}`); + } + + const { t } = useTranslation(); + + const { updateNode } = useWorkflowStore(useZustandShallowSelector(["updateNode"])); + + const formRef = useRef(null); + const [formPending, setFormPending] = useState(false); + + const [drawerOpen, setDrawerOpen] = useState(false); + const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForUpload; + + const wrappedEl = useMemo(() => { + if (node.type !== WorkflowNodeType.Upload) { + console.warn(`[certimate] current workflow node type is not: ${WorkflowNodeType.Upload}`); + } + + if (!node.validated) { + return {t("workflow_node.action.configure_node")}; + } + + const config = (node.config as WorkflowNodeConfigForUpload) ?? {}; + return ( + + {config.domains ?? ""} + + ); + }, [node]); + + const handleDrawerConfirm = async () => { + setFormPending(true); + try { + await formRef.current!.validateFields(); + } catch (err) { + setFormPending(false); + throw err; + } + + try { + const newValues = getFormValues(); + const newNode = produce(node, (draft) => { + draft.config = { + ...newValues, + }; + draft.validated = true; + }); + await updateNode(newNode); + } finally { + setFormPending(false); + } + }; + + return ( + <> + setDrawerOpen(true)}> + {wrappedEl} + + + setDrawerOpen(open)} + getFormValues={() => formRef.current!.getFieldsValue()} + > + + + + ); +}; + +export default memo(UploadNode); diff --git a/ui/src/components/workflow/node/UploadNodeConfigForm.tsx b/ui/src/components/workflow/node/UploadNodeConfigForm.tsx new file mode 100644 index 00000000..62d60569 --- /dev/null +++ b/ui/src/components/workflow/node/UploadNodeConfigForm.tsx @@ -0,0 +1,180 @@ +import { forwardRef, memo, useImperativeHandle } from "react"; +import { useTranslation } from "react-i18next"; +import { UploadOutlined as UploadOutlinedIcon } from "@ant-design/icons"; +import { Button, Form, type FormInstance, Input, Upload, type UploadProps } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import { z } from "zod"; + +import { validateCertificate, validatePrivateKey } from "@/api/certificates"; +import { type WorkflowNodeConfigForUpload } from "@/domain/workflow"; +import { useAntdForm } from "@/hooks"; +import { getErrMsg } from "@/utils/error"; +import { readFileContent } from "@/utils/file"; + +type UploadNodeConfigFormFieldValues = Partial; + +export type UploadNodeConfigFormProps = { + className?: string; + style?: React.CSSProperties; + disabled?: boolean; + initialValues?: UploadNodeConfigFormFieldValues; + onValuesChange?: (values: UploadNodeConfigFormFieldValues) => void; +}; + +export type UploadNodeConfigFormInstance = { + getFieldsValue: () => ReturnType["getFieldsValue"]>; + resetFields: FormInstance["resetFields"]; + validateFields: FormInstance["validateFields"]; +}; + +const initFormModel = (): UploadNodeConfigFormFieldValues => { + return {}; +}; + +const UploadNodeConfigForm = forwardRef( + ({ className, style, disabled, initialValues, onValuesChange }, ref) => { + const { t } = useTranslation(); + + const formSchema = z.object({ + domains: z.string().nullish(), + certificate: z + .string({ message: t("workflow_node.upload.form.certificate.placeholder") }) + .min(1, t("workflow_node.upload.form.certificate.placeholder")) + .max(5120, t("common.errmsg.string_max", { max: 5120 })), + privateKey: z + .string({ message: t("workflow_node.upload.form.private_key.placeholder") }) + .min(1, t("workflow_node.upload.form.private_key.placeholder")) + .max(5120, t("common.errmsg.string_max", { max: 5120 })), + }); + const formRule = createSchemaFieldRule(formSchema); + const { form: formInst, formProps } = useAntdForm({ + name: "workflowNodeUploadConfigForm", + initialValues: initialValues ?? initFormModel(), + }); + + const fieldCertificate = Form.useWatch("certificate", formInst); + const fieldPrivateKey = Form.useWatch("privateKey", formInst); + + const handleFormChange = (_: unknown, values: z.infer) => { + onValuesChange?.(values as UploadNodeConfigFormFieldValues); + }; + + useImperativeHandle(ref, () => { + return { + getFieldsValue: () => { + return formInst.getFieldsValue(true); + }, + resetFields: (fields) => { + return formInst.resetFields(fields as (keyof UploadNodeConfigFormFieldValues)[]); + }, + validateFields: (nameList, config) => { + return formInst.validateFields(nameList, config); + }, + } as UploadNodeConfigFormInstance; + }); + + const handleCertificateFileChange: UploadProps["onChange"] = async ({ file }) => { + if (file && file.status !== "removed") { + const certificate = await readFileContent(file.originFileObj ?? (file as unknown as File)); + + try { + const resp = await validateCertificate(certificate); + formInst.setFields([ + { + name: "domains", + value: resp.data.domains, + }, + { + name: "certificate", + value: certificate, + }, + ]); + } catch (e) { + formInst.setFields([ + { + name: "domains", + value: "", + }, + { + name: "certificate", + value: "", + errors: [getErrMsg(e)], + }, + ]); + } + } else { + formInst.setFieldValue("certificate", ""); + } + + onValuesChange?.(formInst.getFieldsValue(true)); + }; + + const handlePrivateKeyFileChange: UploadProps["onChange"] = async ({ file }) => { + if (file && file.status !== "removed") { + const privateKey = await readFileContent(file.originFileObj ?? (file as unknown as File)); + + try { + await validatePrivateKey(privateKey); + formInst.setFields([ + { + name: "privateKey", + value: privateKey, + }, + ]); + } catch (e) { + formInst.setFields([ + { + name: "privateKey", + value: "", + errors: [getErrMsg(e)], + }, + ]); + } + } else { + formInst.setFieldValue("privateKey", ""); + } + + onValuesChange?.(formInst.getFieldsValue(true)); + }; + + return ( +
+ + + + + + + + + + false} maxCount={1} onChange={handleCertificateFileChange}> + + + + + + + + + + false} maxCount={1} onChange={handlePrivateKeyFileChange}> + + + +
+ ); + } +); + +export default memo(UploadNodeConfigForm); diff --git a/ui/src/domain/workflow.ts b/ui/src/domain/workflow.ts index 8c8e1f79..d001f2e9 100644 --- a/ui/src/domain/workflow.ts +++ b/ui/src/domain/workflow.ts @@ -30,6 +30,7 @@ export enum WorkflowNodeType { Start = "start", End = "end", Apply = "apply", + Upload = "upload", Deploy = "deploy", Notify = "notify", Branch = "branch", @@ -44,6 +45,7 @@ const workflowNodeTypeDefaultNames: Map = new Map([ [WorkflowNodeType.Start, i18n.t("workflow_node.start.label")], [WorkflowNodeType.End, i18n.t("workflow_node.end.label")], [WorkflowNodeType.Apply, i18n.t("workflow_node.apply.label")], + [WorkflowNodeType.Upload, i18n.t("workflow_node.upload.label")], [WorkflowNodeType.Deploy, i18n.t("workflow_node.deploy.label")], [WorkflowNodeType.Notify, i18n.t("workflow_node.notify.label")], [WorkflowNodeType.Branch, i18n.t("workflow_node.branch.label")], @@ -82,6 +84,17 @@ const workflowNodeTypeDefaultOutputs: Map = }, ], ], + [ + WorkflowNodeType.Upload, + [ + { + name: "certificate", + type: "certificate", + required: true, + label: "证书", + }, + ], + ], [WorkflowNodeType.Deploy, []], [WorkflowNodeType.Notify, []], ]); @@ -121,6 +134,13 @@ export type WorkflowNodeConfigForApply = { skipBeforeExpiryDays: number; }; +export type WorkflowNodeConfigForUpload = { + certificateId: string; + domains: string; + certificate: string; + privateKey: string; +}; + export type WorkflowNodeConfigForDeploy = { certificate: string; provider: string; @@ -202,6 +222,7 @@ export const newNode = (nodeType: WorkflowNodeType, options: NewNodeOptions = {} switch (nodeType) { case WorkflowNodeType.Apply: + case WorkflowNodeType.Upload: case WorkflowNodeType.Deploy: { node.inputs = workflowNodeTypeDefaultInputs.get(nodeType); @@ -220,11 +241,13 @@ export const newNode = (nodeType: WorkflowNodeType, options: NewNodeOptions = {} node.branches = [newNode(WorkflowNodeType.Condition, { branchIndex: 0 }), newNode(WorkflowNodeType.Condition, { branchIndex: 1 })]; } break; + case WorkflowNodeType.ExecuteResultBranch: { node.branches = [newNode(WorkflowNodeType.ExecuteSuccess), newNode(WorkflowNodeType.ExecuteFailure)]; } break; + case WorkflowNodeType.ExecuteSuccess: case WorkflowNodeType.ExecuteFailure: { diff --git a/ui/src/i18n/locales/en/nls.workflow.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json index 6cb15e5c..edd26b74 100644 --- a/ui/src/i18n/locales/en/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/en/nls.workflow.nodes.json @@ -378,6 +378,16 @@ "workflow_node.notify.form.channel.placeholder": "Please select channel", "workflow_node.notify.form.channel.button": "Configure", + "workflow_node.upload.label": "Upload", + "workflow_node.upload.form.domains.label": "Domains", + "workflow_node.upload.form.domains.placholder": "Please select certificate file", + "workflow_node.upload.form.certificate.label": "Certificate (PEM format)", + "workflow_node.upload.form.certificate.placeholder": "-----BEGIN CERTIFICATE-----...-----END CERTIFICATE-----", + "workflow_node.upload.form.certificate.button": "Choose file ...", + "workflow_node.upload.form.private_key.label": "Private key (PEM format)", + "workflow_node.upload.form.private_key.placeholder": "-----BEGIN (RSA|EC) PRIVATE KEY-----...-----END(RSA|EC) PRIVATE KEY-----", + "workflow_node.upload.form.private_key.button": "Choose file ...", + "workflow_node.end.label": "End", "workflow_node.branch.label": "Parallel branch", diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index de170659..4eddbd92 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -378,6 +378,16 @@ "workflow_node.notify.form.channel.placeholder": "请选择通知渠道", "workflow_node.notify.form.channel.button": "去配置", + "workflow_node.upload.label": "上传", + "workflow_node.upload.form.domains.label": "域名", + "workflow_node.upload.form.domains.placeholder": "上传证书文件后显示", + "workflow_node.upload.form.certificate.label": "证书文件(PEM 格式)", + "workflow_node.upload.form.certificate.placeholder": "-----BEGIN CERTIFICATE-----...-----END CERTIFICATE-----", + "workflow_node.upload.form.certificate.button": "选择文件", + "workflow_node.upload.form.private_key.label": "私钥文件(PEM 格式)", + "workflow_node.upload.form.private_key.placeholder": "-----BEGIN (RSA|EC) PRIVATE KEY-----...-----END(RSA|EC) PRIVATE KEY-----", + "workflow_node.upload.form.private_key.button": "选择文件", + "workflow_node.end.label": "结束", "workflow_node.branch.label": "并行分支", diff --git a/ui/src/pages/workflows/WorkflowDetail.tsx b/ui/src/pages/workflows/WorkflowDetail.tsx index 8fd341dc..8df424b0 100644 --- a/ui/src/pages/workflows/WorkflowDetail.tsx +++ b/ui/src/pages/workflows/WorkflowDetail.tsx @@ -7,7 +7,10 @@ import { DeleteOutlined as DeleteOutlinedIcon, DownOutlined as DownOutlinedIcon, EllipsisOutlined as EllipsisOutlinedIcon, + ExpandOutlined as ExpandOutlinedIcon, HistoryOutlined as HistoryOutlinedIcon, + MinusOutlined as MinusOutlinedIcon, + PlusOutlined as PlusOutlinedIcon, UndoOutlined as UndoOutlinedIcon, } from "@ant-design/icons"; import { PageHeader } from "@ant-design/pro-components"; @@ -37,6 +40,8 @@ const WorkflowDetail = () => { const [modalApi, ModalContextHolder] = Modal.useModal(); const [notificationApi, NotificationContextHolder] = notification.useNotification(); + const [scale, setScale] = useState(1); + const { id: workflowId } = useParams(); const { workflow, initialized, ...workflowState } = useWorkflowStore( useZustandShallowSelector(["workflow", "initialized", "init", "destroy", "setEnabled", "release", "discard"]) @@ -269,7 +274,7 @@ const WorkflowDetail = () => { {t("workflow.detail.orchestration.draft.alert")}} type="warning" /> -
+
-
+
+
+ +