Merge branch 'next' into feat/new-workflow

This commit is contained in:
Fu Diwei 2025-01-22 20:21:32 +08:00
commit 7a2fc5e2fd
16 changed files with 589 additions and 5 deletions

15
.github/FUNDING.yml vendored Normal file
View File

@ -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/"]

View File

@ -5,9 +5,12 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/usual2970/certimate/internal/app" "github.com/usual2970/certimate/internal/app"
"github.com/usual2970/certimate/internal/domain" "github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/domain/dtos" "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 { func buildExpireSoonNotification(certificates []*domain.Certificate) *struct {
Subject string Subject string
Message string Message string

View File

@ -4,3 +4,28 @@ type CertificateArchiveFileReq struct {
CertificateId string `json:"-"` CertificateId string `json:"-"`
Format string `json:"format"` 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"`
}

View File

@ -29,6 +29,7 @@ const (
WorkflowNodeTypeStart = WorkflowNodeType("start") WorkflowNodeTypeStart = WorkflowNodeType("start")
WorkflowNodeTypeEnd = WorkflowNodeType("end") WorkflowNodeTypeEnd = WorkflowNodeType("end")
WorkflowNodeTypeApply = WorkflowNodeType("apply") WorkflowNodeTypeApply = WorkflowNodeType("apply")
WorkflowNodeTypeUpload = WorkflowNodeType("upload")
WorkflowNodeTypeDeploy = WorkflowNodeType("deploy") WorkflowNodeTypeDeploy = WorkflowNodeType("deploy")
WorkflowNodeTypeNotify = WorkflowNodeType("notify") WorkflowNodeTypeNotify = WorkflowNodeType("notify")
WorkflowNodeTypeBranch = WorkflowNodeType("branch") WorkflowNodeTypeBranch = WorkflowNodeType("branch")
@ -75,6 +76,12 @@ type WorkflowNodeConfigForApply struct {
SkipBeforeExpiryDays int32 `json:"skipBeforeExpiryDays"` // 证书到期前多少天前跳过续期(零值将使用默认值 30 SkipBeforeExpiryDays int32 `json:"skipBeforeExpiryDays"` // 证书到期前多少天前跳过续期(零值将使用默认值 30
} }
type WorkflowNodeConfigForUpload struct {
Certificate string `json:"certificate"`
PrivateKey string `json:"privateKey"`
Domains string `json:"domains"`
}
type WorkflowNodeConfigForDeploy struct { type WorkflowNodeConfigForDeploy struct {
Certificate string `json:"certificate"` // 前序节点输出的证书,形如“${NodeId}#certificate” Certificate string `json:"certificate"` // 前序节点输出的证书,形如“${NodeId}#certificate”
Provider string `json:"provider"` // 主机提供商 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 { func (n *WorkflowNode) GetConfigForDeploy() WorkflowNodeConfigForDeploy {
return WorkflowNodeConfigForDeploy{ return WorkflowNodeConfigForDeploy{
Certificate: n.getConfigValueAsString("certificate"), Certificate: n.getConfigValueAsString("certificate"),

View File

@ -12,6 +12,8 @@ import (
type certificateService interface { type certificateService interface {
ArchiveFile(ctx context.Context, req *dtos.CertificateArchiveFileReq) ([]byte, error) 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 { type CertificateHandler struct {
@ -24,10 +26,12 @@ func NewCertificateHandler(router *router.RouterGroup[*core.RequestEvent], servi
} }
group := router.Group("/certificates") 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 := &dtos.CertificateArchiveFileReq{}
req.CertificateId = e.Request.PathValue("certificateId") req.CertificateId = e.Request.PathValue("certificateId")
if err := e.BindBody(req); err != nil { if err := e.BindBody(req); err != nil {
@ -40,3 +44,27 @@ func (handler *CertificateHandler) run(e *core.RequestEvent) error {
return resp.Ok(e, bt) 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)
}
}

View File

@ -66,6 +66,8 @@ func GetProcessor(node *domain.WorkflowNode) (NodeProcessor, error) {
return NewConditionNode(node), nil return NewConditionNode(node), nil
case domain.WorkflowNodeTypeApply: case domain.WorkflowNodeTypeApply:
return NewApplyNode(node), nil return NewApplyNode(node), nil
case domain.WorkflowNodeTypeUpload:
return NewUploadNode(node), nil
case domain.WorkflowNodeTypeDeploy: case domain.WorkflowNodeTypeDeploy:
return NewDeployNode(node), nil return NewDeployNode(node), nil
case domain.WorkflowNodeTypeNotify: case domain.WorkflowNodeTypeNotify:

View File

@ -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
}

View File

@ -22,3 +22,45 @@ export const archive = async (certificateId: string, format?: CertificateFormatT
return resp; return resp;
}; };
type ValidateCertificateResp = {
domains: string;
};
export const validateCertificate = async (certificate: string) => {
const pb = getPocketBase();
const resp = await pb.send<BaseResponse<ValidateCertificateResp>>(`/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<BaseResponse>(`/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;
};

View File

@ -11,6 +11,7 @@ import ExecuteResultBranchNode from "./node/ExecuteResultBranchNode";
import ExecuteResultNode from "./node/ExecuteResultNode"; import ExecuteResultNode from "./node/ExecuteResultNode";
import NotifyNode from "./node/NotifyNode"; import NotifyNode from "./node/NotifyNode";
import StartNode from "./node/StartNode"; import StartNode from "./node/StartNode";
import UploadNode from "./node/UploadNode";
export type WorkflowElementProps = { export type WorkflowElementProps = {
node: WorkflowNode; node: WorkflowNode;
@ -28,6 +29,9 @@ const WorkflowElement = ({ node, disabled, branchId, branchIndex }: WorkflowElem
case WorkflowNodeType.Apply: case WorkflowNodeType.Apply:
return <ApplyNode node={node} disabled={disabled} />; return <ApplyNode node={node} disabled={disabled} />;
case WorkflowNodeType.Upload:
return <UploadNode node={node} disabled={disabled} />;
case WorkflowNodeType.Deploy: case WorkflowNodeType.Deploy:
return <DeployNode node={node} disabled={disabled} />; return <DeployNode node={node} disabled={disabled} />;
@ -60,3 +64,4 @@ const WorkflowElement = ({ node, disabled, branchId, branchIndex }: WorkflowElem
}; };
export default memo(WorkflowElement); export default memo(WorkflowElement);

View File

@ -2,6 +2,7 @@ import { memo, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
CloudUploadOutlined as CloudUploadOutlinedIcon, CloudUploadOutlined as CloudUploadOutlinedIcon,
DeploymentUnitOutlined as DeploymentUnitOutlinedIcon,
PlusOutlined as PlusOutlinedIcon, PlusOutlined as PlusOutlinedIcon,
SendOutlined as SendOutlinedIcon, SendOutlined as SendOutlinedIcon,
SisternodeOutlined as SisternodeOutlinedIcon, SisternodeOutlined as SisternodeOutlinedIcon,
@ -25,7 +26,8 @@ const AddNode = ({ node, disabled }: AddNodeProps) => {
const dropdownMenus = useMemo(() => { const dropdownMenus = useMemo(() => {
return [ return [
[WorkflowNodeType.Apply, "workflow_node.apply.label", <SolutionOutlinedIcon />], [WorkflowNodeType.Apply, "workflow_node.apply.label", <SolutionOutlinedIcon />],
[WorkflowNodeType.Deploy, "workflow_node.deploy.label", <CloudUploadOutlinedIcon />], [WorkflowNodeType.Upload, "workflow_node.upload.label", <CloudUploadOutlinedIcon />],
[WorkflowNodeType.Deploy, "workflow_node.deploy.label", <DeploymentUnitOutlinedIcon />],
[WorkflowNodeType.Notify, "workflow_node.notify.label", <SendOutlinedIcon />], [WorkflowNodeType.Notify, "workflow_node.notify.label", <SendOutlinedIcon />],
[WorkflowNodeType.Branch, "workflow_node.branch.label", <SisternodeOutlinedIcon />], [WorkflowNodeType.Branch, "workflow_node.branch.label", <SisternodeOutlinedIcon />],
[WorkflowNodeType.ExecuteResultBranch, "workflow_node.execute_result_branch.label", <SisternodeOutlinedIcon />], [WorkflowNodeType.ExecuteResultBranch, "workflow_node.execute_result_branch.label", <SisternodeOutlinedIcon />],

View File

@ -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<UploadNodeConfigFormInstance>(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 <Typography.Link>{t("workflow_node.action.configure_node")}</Typography.Link>;
}
const config = (node.config as WorkflowNodeConfigForUpload) ?? {};
return (
<Flex className="size-full overflow-hidden" align="center" gap={8}>
<Typography.Text className="truncate">{config.domains ?? ""}</Typography.Text>
</Flex>
);
}, [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 (
<>
<SharedNode.Block node={node} disabled={disabled} onClick={() => setDrawerOpen(true)}>
{wrappedEl}
</SharedNode.Block>
<SharedNode.ConfigDrawer
node={node}
open={drawerOpen}
pending={formPending}
onConfirm={handleDrawerConfirm}
onOpenChange={(open) => setDrawerOpen(open)}
getFormValues={() => formRef.current!.getFieldsValue()}
>
<UploadNodeConfigForm ref={formRef} disabled={disabled} initialValues={node.config} />
</SharedNode.ConfigDrawer>
</>
);
};
export default memo(UploadNode);

View File

@ -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<WorkflowNodeConfigForUpload>;
export type UploadNodeConfigFormProps = {
className?: string;
style?: React.CSSProperties;
disabled?: boolean;
initialValues?: UploadNodeConfigFormFieldValues;
onValuesChange?: (values: UploadNodeConfigFormFieldValues) => void;
};
export type UploadNodeConfigFormInstance = {
getFieldsValue: () => ReturnType<FormInstance<UploadNodeConfigFormFieldValues>["getFieldsValue"]>;
resetFields: FormInstance<UploadNodeConfigFormFieldValues>["resetFields"];
validateFields: FormInstance<UploadNodeConfigFormFieldValues>["validateFields"];
};
const initFormModel = (): UploadNodeConfigFormFieldValues => {
return {};
};
const UploadNodeConfigForm = forwardRef<UploadNodeConfigFormInstance, UploadNodeConfigFormProps>(
({ 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<typeof formSchema>) => {
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 (
<Form className={className} style={style} {...formProps} disabled={disabled} layout="vertical" scrollToFirstError onValuesChange={handleFormChange}>
<Form.Item name="domains" label={t("workflow_node.upload.form.domains.label")} rules={[formRule]}>
<Input placeholder={t("workflow_node.upload.form.domains.placeholder")} readOnly />
</Form.Item>
<Form.Item name="certificate" label={t("workflow_node.upload.form.certificate.label")} rules={[formRule]}>
<Input.TextArea
readOnly
autoSize={{ minRows: 5, maxRows: 10 }}
placeholder={t("workflow_node.upload.form.certificate.placeholder")}
value={fieldCertificate}
/>
</Form.Item>
<Form.Item>
<Upload beforeUpload={() => false} maxCount={1} onChange={handleCertificateFileChange}>
<Button icon={<UploadOutlinedIcon />}>{t("workflow_node.upload.form.certificate.button")}</Button>
</Upload>
</Form.Item>
<Form.Item name="privateKey" label={t("workflow_node.upload.form.private_key.label")} rules={[formRule]}>
<Input.TextArea
readOnly
autoSize={{ minRows: 5, maxRows: 10 }}
placeholder={t("workflow_node.upload.form.private_key.placeholder")}
value={fieldPrivateKey}
/>
</Form.Item>
<Form.Item>
<Upload beforeUpload={() => false} maxCount={1} onChange={handlePrivateKeyFileChange}>
<Button icon={<UploadOutlinedIcon />}>{t("workflow_node.upload.form.private_key.button")}</Button>
</Upload>
</Form.Item>
</Form>
);
}
);
export default memo(UploadNodeConfigForm);

View File

@ -30,6 +30,7 @@ export enum WorkflowNodeType {
Start = "start", Start = "start",
End = "end", End = "end",
Apply = "apply", Apply = "apply",
Upload = "upload",
Deploy = "deploy", Deploy = "deploy",
Notify = "notify", Notify = "notify",
Branch = "branch", Branch = "branch",
@ -44,6 +45,7 @@ const workflowNodeTypeDefaultNames: Map<WorkflowNodeType, string> = new Map([
[WorkflowNodeType.Start, i18n.t("workflow_node.start.label")], [WorkflowNodeType.Start, i18n.t("workflow_node.start.label")],
[WorkflowNodeType.End, i18n.t("workflow_node.end.label")], [WorkflowNodeType.End, i18n.t("workflow_node.end.label")],
[WorkflowNodeType.Apply, i18n.t("workflow_node.apply.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.Deploy, i18n.t("workflow_node.deploy.label")],
[WorkflowNodeType.Notify, i18n.t("workflow_node.notify.label")], [WorkflowNodeType.Notify, i18n.t("workflow_node.notify.label")],
[WorkflowNodeType.Branch, i18n.t("workflow_node.branch.label")], [WorkflowNodeType.Branch, i18n.t("workflow_node.branch.label")],
@ -82,6 +84,17 @@ const workflowNodeTypeDefaultOutputs: Map<WorkflowNodeType, WorkflowNodeIO[]> =
}, },
], ],
], ],
[
WorkflowNodeType.Upload,
[
{
name: "certificate",
type: "certificate",
required: true,
label: "证书",
},
],
],
[WorkflowNodeType.Deploy, []], [WorkflowNodeType.Deploy, []],
[WorkflowNodeType.Notify, []], [WorkflowNodeType.Notify, []],
]); ]);
@ -121,6 +134,13 @@ export type WorkflowNodeConfigForApply = {
skipBeforeExpiryDays: number; skipBeforeExpiryDays: number;
}; };
export type WorkflowNodeConfigForUpload = {
certificateId: string;
domains: string;
certificate: string;
privateKey: string;
};
export type WorkflowNodeConfigForDeploy = { export type WorkflowNodeConfigForDeploy = {
certificate: string; certificate: string;
provider: string; provider: string;
@ -202,6 +222,7 @@ export const newNode = (nodeType: WorkflowNodeType, options: NewNodeOptions = {}
switch (nodeType) { switch (nodeType) {
case WorkflowNodeType.Apply: case WorkflowNodeType.Apply:
case WorkflowNodeType.Upload:
case WorkflowNodeType.Deploy: case WorkflowNodeType.Deploy:
{ {
node.inputs = workflowNodeTypeDefaultInputs.get(nodeType); 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 })]; node.branches = [newNode(WorkflowNodeType.Condition, { branchIndex: 0 }), newNode(WorkflowNodeType.Condition, { branchIndex: 1 })];
} }
break; break;
case WorkflowNodeType.ExecuteResultBranch: case WorkflowNodeType.ExecuteResultBranch:
{ {
node.branches = [newNode(WorkflowNodeType.ExecuteSuccess), newNode(WorkflowNodeType.ExecuteFailure)]; node.branches = [newNode(WorkflowNodeType.ExecuteSuccess), newNode(WorkflowNodeType.ExecuteFailure)];
} }
break; break;
case WorkflowNodeType.ExecuteSuccess: case WorkflowNodeType.ExecuteSuccess:
case WorkflowNodeType.ExecuteFailure: case WorkflowNodeType.ExecuteFailure:
{ {

View File

@ -378,6 +378,16 @@
"workflow_node.notify.form.channel.placeholder": "Please select channel", "workflow_node.notify.form.channel.placeholder": "Please select channel",
"workflow_node.notify.form.channel.button": "Configure", "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.end.label": "End",
"workflow_node.branch.label": "Parallel branch", "workflow_node.branch.label": "Parallel branch",

View File

@ -378,6 +378,16 @@
"workflow_node.notify.form.channel.placeholder": "请选择通知渠道", "workflow_node.notify.form.channel.placeholder": "请选择通知渠道",
"workflow_node.notify.form.channel.button": "去配置", "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.end.label": "结束",
"workflow_node.branch.label": "并行分支", "workflow_node.branch.label": "并行分支",

View File

@ -7,7 +7,10 @@ import {
DeleteOutlined as DeleteOutlinedIcon, DeleteOutlined as DeleteOutlinedIcon,
DownOutlined as DownOutlinedIcon, DownOutlined as DownOutlinedIcon,
EllipsisOutlined as EllipsisOutlinedIcon, EllipsisOutlined as EllipsisOutlinedIcon,
ExpandOutlined as ExpandOutlinedIcon,
HistoryOutlined as HistoryOutlinedIcon, HistoryOutlined as HistoryOutlinedIcon,
MinusOutlined as MinusOutlinedIcon,
PlusOutlined as PlusOutlinedIcon,
UndoOutlined as UndoOutlinedIcon, UndoOutlined as UndoOutlinedIcon,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { PageHeader } from "@ant-design/pro-components"; import { PageHeader } from "@ant-design/pro-components";
@ -37,6 +40,8 @@ const WorkflowDetail = () => {
const [modalApi, ModalContextHolder] = Modal.useModal(); const [modalApi, ModalContextHolder] = Modal.useModal();
const [notificationApi, NotificationContextHolder] = notification.useNotification(); const [notificationApi, NotificationContextHolder] = notification.useNotification();
const [scale, setScale] = useState(1);
const { id: workflowId } = useParams(); const { id: workflowId } = useParams();
const { workflow, initialized, ...workflowState } = useWorkflowStore( const { workflow, initialized, ...workflowState } = useWorkflowStore(
useZustandShallowSelector(["workflow", "initialized", "init", "destroy", "setEnabled", "release", "discard"]) useZustandShallowSelector(["workflow", "initialized", "init", "destroy", "setEnabled", "release", "discard"])
@ -300,7 +305,14 @@ const WorkflowDetail = () => {
</Space> </Space>
</div> </div>
</div> </div>
<div className="px-12 py-8 max-md:px-4"> <div className="fixed bottom-8 right-8 z-10 flex items-center gap-2 rounded-lg bg-white p-2 shadow-lg">
<Button icon={<MinusOutlinedIcon />} disabled={scale <= 0.5} onClick={() => setScale((s) => Math.max(0.5, s - 0.1))} />
<Typography.Text className="min-w-[3em] text-center">{Math.round(scale * 100)}%</Typography.Text>
<Button icon={<PlusOutlinedIcon />} disabled={scale >= 2} onClick={() => setScale((s) => Math.min(2, s + 0.1))} />
<Button icon={<ExpandOutlinedIcon />} onClick={() => setScale(1)} />
</div>
<div className="size-full origin-top px-12 py-8 transition-transform duration-300 max-md:px-4" style={{ transform: `scale(${scale})` }}>
<WorkflowElements /> <WorkflowElements />
</div> </div>
</div> </div>