From b8ab077b57ce234fbd68363479dc5f24f17bc88b Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Mon, 19 May 2025 17:41:39 +0800 Subject: [PATCH 01/14] improve ui --- .../workflow/node/ConditionNode.tsx | 53 +++- .../workflow/node/ConditionNodeConfigForm.tsx | 250 ++++++++++++++++++ ui/src/domain/workflow.ts | 47 +++- ui/src/stores/workflow/index.ts | 4 +- 4 files changed, 346 insertions(+), 8 deletions(-) create mode 100644 ui/src/components/workflow/node/ConditionNodeConfigForm.tsx diff --git a/ui/src/components/workflow/node/ConditionNode.tsx b/ui/src/components/workflow/node/ConditionNode.tsx index 56639692..63aa1aa1 100644 --- a/ui/src/components/workflow/node/ConditionNode.tsx +++ b/ui/src/components/workflow/node/ConditionNode.tsx @@ -1,9 +1,14 @@ -import { memo } from "react"; +import { memo, useRef, useState } from "react"; import { MoreOutlined as MoreOutlinedIcon } from "@ant-design/icons"; import { Button, Card, Popover } from "antd"; import SharedNode, { type SharedNodeProps } from "./_SharedNode"; import AddNode from "./AddNode"; +import ConditionNodeConfigForm, { ConditionNodeConfigFormInstance } from "./ConditionNodeConfigForm"; +import { WorkflowNodeConfigForCondition } from "@/domain/workflow"; +import { produce } from "immer"; +import { useWorkflowStore } from "@/stores/workflow"; +import { useZustandShallowSelector } from "@/hooks"; export type ConditionNodeProps = SharedNodeProps & { branchId: string; @@ -11,7 +16,37 @@ export type ConditionNodeProps = SharedNodeProps & { }; const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeProps) => { - // TODO: 条件分支 + const { updateNode } = useWorkflowStore(useZustandShallowSelector(["updateNode"])); + + const [formPending, setFormPending] = useState(false); + const formRef = useRef(null); + + const [drawerOpen, setDrawerOpen] = useState(false); + + const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForCondition; + + 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 ( <> @@ -30,7 +65,7 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP } placement="rightTop" > - + setDrawerOpen(true)}>
+ + setDrawerOpen(open)} + getFormValues={() => formRef.current!.getFieldsValue()} + > + + @@ -47,3 +93,4 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP }; export default memo(ConditionNode); + diff --git a/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx b/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx new file mode 100644 index 00000000..4eca1617 --- /dev/null +++ b/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx @@ -0,0 +1,250 @@ +import { forwardRef, memo, useEffect, useImperativeHandle, useState } from "react"; +import { Button, Card, Form, Input, Select, Space, Radio } from "antd"; +import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"; + +import { + WorkflowNodeConfigForCondition, + Expr, + WorkflowNodeIOValueSelector, + ComparisonOperator, + LogicalOperator, + isConstExpr, + isVarExpr, + WorkflowNode, +} from "@/domain/workflow"; +import { FormInstance } from "antd"; +import { useZustandShallowSelector } from "@/hooks"; +import { useWorkflowStore } from "@/stores/workflow"; + +// 表单内部使用的扁平结构 - 修改后只保留必要字段 +interface ConditionItem { + leftSelector: WorkflowNodeIOValueSelector; + operator: ComparisonOperator; + rightValue: string; +} + +type ConditionNodeConfigFormFieldValues = { + conditions: ConditionItem[]; + logicalOperator: LogicalOperator; +}; + +export type ConditionNodeConfigFormProps = { + className?: string; + style?: React.CSSProperties; + disabled?: boolean; + initialValues?: Partial; + onValuesChange?: (values: WorkflowNodeConfigForCondition) => void; + availableSelectors?: WorkflowNodeIOValueSelector[]; + nodeId: string; +}; + +export type ConditionNodeConfigFormInstance = { + getFieldsValue: () => ReturnType["getFieldsValue"]>; + resetFields: FormInstance["resetFields"]; + validateFields: FormInstance["validateFields"]; +}; + +// 初始表单值 +const initFormModel = (): ConditionNodeConfigFormFieldValues => { + return { + conditions: [ + { + leftSelector: undefined as unknown as WorkflowNodeIOValueSelector, + operator: "==", + rightValue: "", + }, + ], + logicalOperator: "and", + }; +}; + +// 将表单值转换为表达式结构 +const formToExpression = (values: ConditionNodeConfigFormFieldValues): Expr => { + // 创建单个条件的表达式 + const createComparisonExpr = (condition: ConditionItem): Expr => { + const left: Expr = { type: "var", selector: condition.leftSelector }; + const right: Expr = { type: "const", value: condition.rightValue || "" }; + + return { + type: "compare", + op: condition.operator, + left, + right, + }; + }; + + // 如果只有一个条件,直接返回比较表达式 + if (values.conditions.length === 1) { + return createComparisonExpr(values.conditions[0]); + } + + // 多个条件,通过逻辑运算符连接 + let expr: Expr = createComparisonExpr(values.conditions[0]); + + for (let i = 1; i < values.conditions.length; i++) { + expr = { + type: "logical", + op: values.logicalOperator, + left: expr, + right: createComparisonExpr(values.conditions[i]), + }; + } + + return expr; +}; + +// 递归提取表达式中的条件项 +const expressionToForm = (expr?: Expr): ConditionNodeConfigFormFieldValues => { + if (!expr) return initFormModel(); + + const conditions: ConditionItem[] = []; + let logicalOp: LogicalOperator = "and"; + + const extractComparisons = (expr: Expr): void => { + if (expr.type === "compare") { + // 确保左侧是变量,右侧是常量 + if (isVarExpr(expr.left) && isConstExpr(expr.right)) { + conditions.push({ + leftSelector: expr.left.selector, + operator: expr.op, + rightValue: String(expr.right.value), + }); + } + } else if (expr.type === "logical") { + logicalOp = expr.op; + extractComparisons(expr.left); + extractComparisons(expr.right); + } + }; + + extractComparisons(expr); + + return { + conditions: conditions.length > 0 ? conditions : initFormModel().conditions, + logicalOperator: logicalOp, + }; +}; + +const ConditionNodeConfigForm = forwardRef( + ({ className, style, disabled, initialValues, onValuesChange, nodeId }, ref) => { + const { getWorkflowOuptutBeforeId } = useWorkflowStore(useZustandShallowSelector(["updateNode", "getWorkflowOuptutBeforeId"])); + + const [form] = Form.useForm(); + const [formModel, setFormModel] = useState(initFormModel()); + + const [previousNodes, setPreviousNodes] = useState([]); + useEffect(() => { + const previousNodes = getWorkflowOuptutBeforeId(nodeId); + setPreviousNodes(previousNodes); + }, [nodeId]); + + // 初始化表单值 + useEffect(() => { + if (initialValues?.expression) { + const formValues = expressionToForm(initialValues.expression); + form.setFieldsValue(formValues); + setFormModel(formValues); + } + }, [form, initialValues]); + + // 公开表单方法 + useImperativeHandle( + ref, + () => ({ + getFieldsValue: form.getFieldsValue, + resetFields: form.resetFields, + validateFields: form.validateFields, + }), + [form] + ); + + // 表单值变更处理 + const handleFormChange = (changedValues: any, values: ConditionNodeConfigFormFieldValues) => { + setFormModel(values); + + // 转换为表达式结构并通知父组件 + const expression = formToExpression(values); + onValuesChange?.({ expression }); + }; + + return ( +
+ + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, ...restField }) => ( + 1 ? + + + )} + + + {formModel.conditions && formModel.conditions.length > 1 && ( + + + 满足所有条件 (AND) + 满足任一条件 (OR) + + + )} +
+ ); + } +); + +export default memo(ConditionNodeConfigForm); + diff --git a/ui/src/domain/workflow.ts b/ui/src/domain/workflow.ts index 06226425..762d57ab 100644 --- a/ui/src/domain/workflow.ts +++ b/ui/src/domain/workflow.ts @@ -165,6 +165,10 @@ export type WorkflowNodeConfigForNotify = { providerConfig?: Record; }; +export type WorkflowNodeConfigForCondition = { + expression: Expr; +}; + export type WorkflowNodeConfigForBranch = never; export type WorkflowNodeConfigForEnd = never; @@ -185,6 +189,32 @@ export type WorkflowNodeIOValueSelector = { // #endregion +// #region Condition expression + +type Value = string | number | boolean; + +export type ComparisonOperator = ">" | "<" | ">=" | "<=" | "==" | "!="; + +export type LogicalOperator = "and" | "or" | "not"; + +export type ConstExpr = { type: "const"; value: Value }; +export type VarExpr = { type: "var"; selector: WorkflowNodeIOValueSelector }; +export type CompareExpr = { type: "compare"; op: ComparisonOperator; left: Expr; right: Expr }; +export type LogicalExpr = { type: "logical"; op: LogicalOperator; left: Expr; right: Expr }; +export type NotExpr = { type: "not"; expr: Expr }; + +export type Expr = ConstExpr | VarExpr | CompareExpr | LogicalExpr | NotExpr; + +export const isConstExpr = (expr: Expr): expr is ConstExpr => { + return expr.type === "const"; +}; + +export const isVarExpr = (expr: Expr): expr is VarExpr => { + return expr.type === "var"; +}; + +// #endregion + const isBranchLike = (node: WorkflowNode) => { return node.type === WorkflowNodeType.Branch || node.type === WorkflowNodeType.ExecuteResultBranch; }; @@ -433,7 +463,17 @@ export const removeBranch = (node: WorkflowNode, branchNodeId: string, branchInd }); }; -export const getOutputBeforeNodeId = (root: WorkflowNode, nodeId: string, type: string): WorkflowNode[] => { +const typeEqual = (a: WorkflowNodeIO, t: string) => { + if (t === "all") { + return true; + } + if (a.type === t) { + return true; + } + return false; +}; + +export const getOutputBeforeNodeId = (root: WorkflowNode, nodeId: string, type: string = "all"): WorkflowNode[] => { // 某个分支的节点,不应该能获取到相邻分支上节点的输出 const outputs: WorkflowNode[] = []; @@ -445,10 +485,10 @@ export const getOutputBeforeNodeId = (root: WorkflowNode, nodeId: string, type: return true; } - if (current.type !== WorkflowNodeType.Branch && current.outputs && current.outputs.some((io) => io.type === type)) { + if (current.type !== WorkflowNodeType.Branch && current.outputs && current.outputs.some((io) => typeEqual(io, type))) { output.push({ ...current, - outputs: current.outputs.filter((io) => io.type === type), + outputs: current.outputs.filter((io) => typeEqual(io, type)), }); } @@ -501,3 +541,4 @@ export const isAllNodesValidated = (node: WorkflowNode): boolean => { return true; }; + diff --git a/ui/src/stores/workflow/index.ts b/ui/src/stores/workflow/index.ts index 8057fda5..d20fec16 100644 --- a/ui/src/stores/workflow/index.ts +++ b/ui/src/stores/workflow/index.ts @@ -32,7 +32,7 @@ export type WorkflowState = { addBranch: (branchId: string) => void; removeBranch: (branchId: string, index: number) => void; - getWorkflowOuptutBeforeId: (nodeId: string, type: string) => WorkflowNode[]; + getWorkflowOuptutBeforeId: (nodeId: string, type?: string) => WorkflowNode[]; }; export const useWorkflowStore = create((set, get) => ({ @@ -243,7 +243,7 @@ export const useWorkflowStore = create((set, get) => ({ }); }, - getWorkflowOuptutBeforeId: (nodeId: string, type: string) => { + getWorkflowOuptutBeforeId: (nodeId: string, type: string = "all") => { return getOutputBeforeNodeId(get().workflow.draft as WorkflowNode, nodeId, type); }, })); From 05d43f38cead084f61ed6a6468716579bc6550f2 Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Mon, 19 May 2025 18:15:04 +0800 Subject: [PATCH 02/14] improve previous variables --- .../workflow/node/ConditionNodeConfigForm.tsx | 21 +++++++++++------ ui/src/domain/workflow.ts | 23 +++++++++++++++++++ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx b/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx index 4eca1617..c83a4b68 100644 --- a/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx +++ b/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx @@ -183,13 +183,20 @@ const ConditionNodeConfigForm = forwardRef {/* 左侧变量选择器 */} - + {/* 操作符 */} diff --git a/ui/src/domain/workflow.ts b/ui/src/domain/workflow.ts index 762d57ab..6cccf32e 100644 --- a/ui/src/domain/workflow.ts +++ b/ui/src/domain/workflow.ts @@ -187,6 +187,29 @@ export type WorkflowNodeIOValueSelector = { name: string; }; +export const workflowNodeIOOptions = (node: WorkflowNode, io: WorkflowNodeIO) => { + switch (io.type) { + case "certificate": + return [ + { + label: "是否有效", + value: "valid", + }, + { + label: "剩余有效天数", + value: "valid", + }, + ]; + default: + return [ + { + label: `${node.name} - ${io.label}`, + value: `${node.id}#${io.name}`, + }, + ]; + } +}; + // #endregion // #region Condition expression From 6f054ee5946604324fdabd232fbd0bfc58cd0994 Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Mon, 19 May 2025 18:16:21 +0800 Subject: [PATCH 03/14] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2ab2fb7b..7584fdd1 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ 做个人产品或者在中小企业里负责运维的同学,会遇到要管理多个域名的情况,需要给域名申请证书。但是手动申请证书有以下缺点: -- 😱 麻烦:申请证书并部署到服务的流程虽不复杂,但也挺麻烦的,犹其是你有多个域名需要维护的时候。 +- 😱 麻烦:申请证书并部署到服务的流程虽不复杂,但也挺麻烦的,尤其是你有多个域名需要维护的时候。 - 😭 易忘:另外当前免费证书的有效期只有 90 天,这就要求你定期的操作,增加了工作量的同时,你也很容易忘掉续期,从而导致网站访问不了。 Certimate 就是为了解决上述问题而产生的,它具有以下优势: From 1e67e9333ec9a7d997e8dbe1f28af42121fc13fd Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Mon, 19 May 2025 21:59:37 +0800 Subject: [PATCH 04/14] condition render --- .../workflow/node/ConditionNode.tsx | 44 +++++++++++++-- .../workflow/node/ConditionNodeConfigForm.tsx | 56 ++----------------- ui/src/domain/workflow.ts | 55 +++++++++++------- 3 files changed, 80 insertions(+), 75 deletions(-) diff --git a/ui/src/components/workflow/node/ConditionNode.tsx b/ui/src/components/workflow/node/ConditionNode.tsx index 63aa1aa1..69dc101d 100644 --- a/ui/src/components/workflow/node/ConditionNode.tsx +++ b/ui/src/components/workflow/node/ConditionNode.tsx @@ -4,8 +4,8 @@ import { Button, Card, Popover } from "antd"; import SharedNode, { type SharedNodeProps } from "./_SharedNode"; import AddNode from "./AddNode"; -import ConditionNodeConfigForm, { ConditionNodeConfigFormInstance } from "./ConditionNodeConfigForm"; -import { WorkflowNodeConfigForCondition } from "@/domain/workflow"; +import ConditionNodeConfigForm, { ConditionItem, ConditionNodeConfigFormFieldValues, ConditionNodeConfigFormInstance } from "./ConditionNodeConfigForm"; +import { Expr, WorkflowNodeConfigForCondition } from "@/domain/workflow"; import { produce } from "immer"; import { useWorkflowStore } from "@/stores/workflow"; import { useZustandShallowSelector } from "@/hooks"; @@ -23,7 +23,42 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP const [drawerOpen, setDrawerOpen] = useState(false); - const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForCondition; + const getFormValues = () => formRef.current!.getFieldsValue() as ConditionNodeConfigFormFieldValues; + + // 将表单值转换为表达式结构 + const formToExpression = (values: ConditionNodeConfigFormFieldValues): Expr => { + // 创建单个条件的表达式 + const createComparisonExpr = (condition: ConditionItem): Expr => { + const left: Expr = { type: "var", selector: condition.leftSelector }; + const right: Expr = { type: "const", value: condition.rightValue || "" }; + + return { + type: "compare", + op: condition.operator, + left, + right, + }; + }; + + // 如果只有一个条件,直接返回比较表达式 + if (values.conditions.length === 1) { + return createComparisonExpr(values.conditions[0]); + } + + // 多个条件,通过逻辑运算符连接 + let expr: Expr = createComparisonExpr(values.conditions[0]); + + for (let i = 1; i < values.conditions.length; i++) { + expr = { + type: "logical", + op: values.logicalOperator, + left: expr, + right: createComparisonExpr(values.conditions[i]), + }; + } + + return expr; + }; const handleDrawerConfirm = async () => { setFormPending(true); @@ -36,9 +71,10 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP try { const newValues = getFormValues(); + const expression = formToExpression(newValues); const newNode = produce(node, (draft) => { draft.config = { - ...newValues, + expression, }; draft.validated = true; }); diff --git a/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx b/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx index c83a4b68..52f2c3e2 100644 --- a/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx +++ b/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx @@ -11,19 +11,20 @@ import { isConstExpr, isVarExpr, WorkflowNode, + workflowNodeIOOptions, } from "@/domain/workflow"; import { FormInstance } from "antd"; import { useZustandShallowSelector } from "@/hooks"; import { useWorkflowStore } from "@/stores/workflow"; // 表单内部使用的扁平结构 - 修改后只保留必要字段 -interface ConditionItem { +export interface ConditionItem { leftSelector: WorkflowNodeIOValueSelector; operator: ComparisonOperator; rightValue: string; } -type ConditionNodeConfigFormFieldValues = { +export type ConditionNodeConfigFormFieldValues = { conditions: ConditionItem[]; logicalOperator: LogicalOperator; }; @@ -58,41 +59,6 @@ const initFormModel = (): ConditionNodeConfigFormFieldValues => { }; }; -// 将表单值转换为表达式结构 -const formToExpression = (values: ConditionNodeConfigFormFieldValues): Expr => { - // 创建单个条件的表达式 - const createComparisonExpr = (condition: ConditionItem): Expr => { - const left: Expr = { type: "var", selector: condition.leftSelector }; - const right: Expr = { type: "const", value: condition.rightValue || "" }; - - return { - type: "compare", - op: condition.operator, - left, - right, - }; - }; - - // 如果只有一个条件,直接返回比较表达式 - if (values.conditions.length === 1) { - return createComparisonExpr(values.conditions[0]); - } - - // 多个条件,通过逻辑运算符连接 - let expr: Expr = createComparisonExpr(values.conditions[0]); - - for (let i = 1; i < values.conditions.length; i++) { - expr = { - type: "logical", - op: values.logicalOperator, - left: expr, - right: createComparisonExpr(values.conditions[i]), - }; - } - - return expr; -}; - // 递归提取表达式中的条件项 const expressionToForm = (expr?: Expr): ConditionNodeConfigFormFieldValues => { if (!expr) return initFormModel(); @@ -159,12 +125,8 @@ const ConditionNodeConfigForm = forwardRef { + const handleFormChange = (_: undefined, values: ConditionNodeConfigFormFieldValues) => { setFormModel(values); - - // 转换为表达式结构并通知父组件 - const expression = formToExpression(values); - onValuesChange?.({ expression }); }; return ( @@ -186,15 +148,7 @@ const ConditionNodeConfigForm = forwardRef { - return { - label: item.name, - options: item.outputs?.map((output) => { - return { - label: `${item.name} - ${output.label}`, - value: `${item.id}#${output.name}`, - }; - }), - }; + return workflowNodeIOOptions(item); })} > diff --git a/ui/src/domain/workflow.ts b/ui/src/domain/workflow.ts index 6cccf32e..d6354e14 100644 --- a/ui/src/domain/workflow.ts +++ b/ui/src/domain/workflow.ts @@ -187,27 +187,42 @@ export type WorkflowNodeIOValueSelector = { name: string; }; -export const workflowNodeIOOptions = (node: WorkflowNode, io: WorkflowNodeIO) => { - switch (io.type) { - case "certificate": - return [ - { - label: "是否有效", - value: "valid", - }, - { - label: "剩余有效天数", - value: "valid", - }, - ]; - default: - return [ - { - label: `${node.name} - ${io.label}`, - value: `${node.id}#${io.name}`, - }, - ]; +type WorkflowNodeIOOptions = { + label: string; + value: string; +}; + +export const workflowNodeIOOptions = (node: WorkflowNode) => { + const rs = { + label: node.name, + options: Array(), + }; + + if (node.outputs) { + for (const output of node.outputs) { + switch (output.type) { + case "certificate": + rs.options.push({ + label: `${node.name} - ${output.label} - 是否有效`, + value: `${node.id}#${output.name}.validated`, + }); + + rs.options.push({ + label: `${node.name} - ${output.label} - 剩余天数`, + value: `${node.id}#${output.name}.daysLeft`, + }); + break; + default: + rs.options.push({ + label: `${node.name} - ${output.label}`, + value: `${node.id}#${output.name}`, + }); + break; + } + } } + + return rs; }; // #endregion From 6353f0139be8f1aeb6501f9597ffa40af055b1d8 Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Tue, 20 May 2025 11:01:04 +0800 Subject: [PATCH 05/14] improve variable types --- .../workflow/node/ConditionNode.tsx | 12 +- .../workflow/node/ConditionNodeConfigForm.tsx | 163 +++++++++++++++--- ui/src/domain/workflow.ts | 11 +- 3 files changed, 160 insertions(+), 26 deletions(-) diff --git a/ui/src/components/workflow/node/ConditionNode.tsx b/ui/src/components/workflow/node/ConditionNode.tsx index 69dc101d..60a35c26 100644 --- a/ui/src/components/workflow/node/ConditionNode.tsx +++ b/ui/src/components/workflow/node/ConditionNode.tsx @@ -5,7 +5,7 @@ import { Button, Card, Popover } from "antd"; import SharedNode, { type SharedNodeProps } from "./_SharedNode"; import AddNode from "./AddNode"; import ConditionNodeConfigForm, { ConditionItem, ConditionNodeConfigFormFieldValues, ConditionNodeConfigFormInstance } from "./ConditionNodeConfigForm"; -import { Expr, WorkflowNodeConfigForCondition } from "@/domain/workflow"; +import { Expr, WorkflowNodeConfigForCondition, WorkflowNodeIoValueType } from "@/domain/workflow"; import { produce } from "immer"; import { useWorkflowStore } from "@/stores/workflow"; import { useZustandShallowSelector } from "@/hooks"; @@ -29,7 +29,15 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP const formToExpression = (values: ConditionNodeConfigFormFieldValues): Expr => { // 创建单个条件的表达式 const createComparisonExpr = (condition: ConditionItem): Expr => { - const left: Expr = { type: "var", selector: condition.leftSelector }; + const selectors = condition.leftSelector.split("#"); + const left: Expr = { + type: "var", + selector: { + id: selectors[0], + name: selectors[1], + type: selectors[2] as WorkflowNodeIoValueType, + }, + }; const right: Expr = { type: "const", value: condition.rightValue || "" }; return { diff --git a/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx b/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx index 52f2c3e2..b041ea7c 100644 --- a/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx +++ b/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx @@ -1,5 +1,5 @@ import { forwardRef, memo, useEffect, useImperativeHandle, useState } from "react"; -import { Button, Card, Form, Input, Select, Space, Radio } from "antd"; +import { Button, Card, Form, Input, Select, Space, Radio, DatePicker } from "antd"; import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"; import { @@ -12,6 +12,7 @@ import { isVarExpr, WorkflowNode, workflowNodeIOOptions, + WorkflowNodeIoValueType, } from "@/domain/workflow"; import { FormInstance } from "antd"; import { useZustandShallowSelector } from "@/hooks"; @@ -19,7 +20,7 @@ import { useWorkflowStore } from "@/stores/workflow"; // 表单内部使用的扁平结构 - 修改后只保留必要字段 export interface ConditionItem { - leftSelector: WorkflowNodeIOValueSelector; + leftSelector: string; operator: ComparisonOperator; rightValue: string; } @@ -50,7 +51,7 @@ const initFormModel = (): ConditionNodeConfigFormFieldValues => { return { conditions: [ { - leftSelector: undefined as unknown as WorkflowNodeIOValueSelector, + leftSelector: "", operator: "==", rightValue: "", }, @@ -71,7 +72,7 @@ const expressionToForm = (expr?: Expr): ConditionNodeConfigFormFieldValues => { // 确保左侧是变量,右侧是常量 if (isVarExpr(expr.left) && isConstExpr(expr.right)) { conditions.push({ - leftSelector: expr.left.selector, + leftSelector: `${expr.left.selector.id}#${expr.left.selector.name}#${expr.left.selector.type}`, operator: expr.op, rightValue: String(expr.right.value), }); @@ -91,6 +92,38 @@ const expressionToForm = (expr?: Expr): ConditionNodeConfigFormFieldValues => { }; }; +// 根据变量类型获取适当的操作符选项 +const getOperatorsByType = (type: string): { value: ComparisonOperator; label: string }[] => { + switch (type) { + case "number": + case "string": + return [ + { value: "==", label: "等于 (==)" }, + { value: "!=", label: "不等于 (!=)" }, + { value: ">", label: "大于 (>)" }, + { value: ">=", label: "大于等于 (>=)" }, + { value: "<", label: "小于 (<)" }, + { value: "<=", label: "小于等于 (<=)" }, + ]; + case "boolean": + return [{ value: "is", label: "为" }]; + default: + return []; + } +}; + +// 从选择器字符串中提取变量类型 +const getVariableTypeFromSelector = (selector: string): string => { + if (!selector) return "string"; + + // 假设选择器格式为 "id#name#type" + const parts = selector.split("#"); + if (parts.length >= 3) { + return parts[2].toLowerCase() || "string"; + } + return "string"; +}; + const ConditionNodeConfigForm = forwardRef( ({ className, style, disabled, initialValues, onValuesChange, nodeId }, ref) => { const { getWorkflowOuptutBeforeId } = useWorkflowStore(useZustandShallowSelector(["updateNode", "getWorkflowOuptutBeforeId"])); @@ -127,6 +160,12 @@ const ConditionNodeConfigForm = forwardRef { setFormModel(values); + + if (onValuesChange) { + // 将表单值转换为表达式 + const expression = formToExpression(values); + onValuesChange({ expression }); + } }; return ( @@ -141,7 +180,6 @@ const ConditionNodeConfigForm = forwardRef 1 ? @@ -266,10 +284,10 @@ const ConditionNodeConfigForm = forwardRef {formModel.conditions && formModel.conditions.length > 1 && ( - + - 满足所有条件 (AND) - 满足任一条件 (OR) + {t(`${prefix}.logical_operator.and`)} + {t(`${prefix}.logical_operator.or`)} )} diff --git a/ui/src/i18n/locales/en/nls.workflow.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json index 7b53e6e4..a0288838 100644 --- a/ui/src/i18n/locales/en/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/en/nls.workflow.nodes.json @@ -790,6 +790,26 @@ "workflow_node.branch.label": "Parallel branch", "workflow_node.condition.label": "Branch", + "workflow_node.condition.form.variable.placeholder": "Please select variable", + "workflow_node.condition.form.variable.errmsg": "Please select variable", + "workflow_node.condition.form.operator.errmsg": "Please select operator", + "workflow_node.condition.form.value.errmsg": "Please enter value", + "workflow_node.condition.form.value.string.placeholder": "Please enter value", + "workflow_node.condition.form.value.number.placeholder": "Please enter value", + "workflow_node.condition.form.value.boolean.placeholder": "Please select value", + "workflow_node.condition.form.value.boolean.true": "True", + "workflow_node.condition.form.value.boolean.false": "False", + "workflow_node.condition.form.add_condition.button": "Add condition", + "workflow_node.condition.form.logical_operator.label": "Logical operator", + "workflow_node.condition.form.logical_operator.and": "Meet all conditions (AND)", + "workflow_node.condition.form.logical_operator.or": "Meet any condition (OR)", + "workflow_node.condition.form.comparison.equal": "Equal", + "workflow_node.condition.form.comparison.not_equal": "Not equal", + "workflow_node.condition.form.comparison.greater_than": "Greater than", + "workflow_node.condition.form.comparison.greater_than_or_equal": "Greater than or equal", + "workflow_node.condition.form.comparison.less_than": "Less than", + "workflow_node.condition.form.comparison.less_than_or_equal": "Less than or equal", + "workflow_node.condition.form.comparison.is": "Is", "workflow_node.execute_result_branch.label": "Execution result branch", @@ -797,3 +817,4 @@ "workflow_node.execute_failure.label": "If the previous node failed ..." } + diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index 89cbfc11..9381aa71 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -789,6 +789,26 @@ "workflow_node.branch.label": "并行分支", "workflow_node.condition.label": "分支", + "workflow_node.condition.form.variable.placeholder": "选择变量", + "workflow_node.condition.form.variable.errmsg": "请选择变量", + "workflow_node.condition.form.operator.errmsg": "请选择操作符", + "workflow_node.condition.form.value.errmsg": "请输入值", + "workflow_node.condition.form.value.string.placeholder": "输入值", + "workflow_node.condition.form.value.number.placeholder": "输入数值", + "workflow_node.condition.form.value.boolean.placeholder": "选择值", + "workflow_node.condition.form.value.boolean.true": "是", + "workflow_node.condition.form.value.boolean.false": "否", + "workflow_node.condition.form.add_condition.button": "添加条件", + "workflow_node.condition.form.logical_operator.label": "条件逻辑", + "workflow_node.condition.form.logical_operator.and": "满足所有条件 (AND)", + "workflow_node.condition.form.logical_operator.or": "满足任一条件 (OR)", + "workflow_node.condition.form.comparison.equal": "等于", + "workflow_node.condition.form.comparison.not_equal": "不等于", + "workflow_node.condition.form.comparison.greater_than": "大于", + "workflow_node.condition.form.comparison.greater_than_or_equal": "大于等于", + "workflow_node.condition.form.comparison.less_than": "小于", + "workflow_node.condition.form.comparison.less_than_or_equal": "小于等于", + "workflow_node.condition.form.comparison.is": "为", "workflow_node.execute_result_branch.label": "执行结果分支", @@ -796,3 +816,4 @@ "workflow_node.execute_failure.label": "若前序节点执行失败…" } + From 97d692910baa5077488072909201ac613f41d3d4 Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Tue, 20 May 2025 18:09:42 +0800 Subject: [PATCH 07/14] expression evaluate --- internal/domain/expr.go | 262 ++++++++++++++++++ internal/domain/workflow.go | 21 ++ internal/workflow/dispatcher/invoker.go | 5 + .../workflow/node-processor/apply_node.go | 11 + .../workflow/node-processor/condition_node.go | 30 +- internal/workflow/node-processor/context.go | 126 +++++++++ .../workflow/node-processor/deploy_node.go | 2 + .../node-processor/execute_failure_node.go | 2 + .../node-processor/execute_success_node.go | 2 + .../workflow/node-processor/notify_node.go | 2 + internal/workflow/node-processor/processor.go | 16 ++ .../workflow/node-processor/start_node.go | 2 + .../workflow/node-processor/upload_node.go | 8 + .../workflow/node/ConditionNode.tsx | 25 +- ui/src/domain/workflow.ts | 2 +- 15 files changed, 511 insertions(+), 5 deletions(-) create mode 100644 internal/domain/expr.go create mode 100644 internal/workflow/node-processor/context.go diff --git a/internal/domain/expr.go b/internal/domain/expr.go new file mode 100644 index 00000000..3b312642 --- /dev/null +++ b/internal/domain/expr.go @@ -0,0 +1,262 @@ +package domain + +import ( + "encoding/json" + "fmt" +) + +type Value any + +type ( + ComparisonOperator string + LogicalOperator string +) + +const ( + GreaterThan ComparisonOperator = ">" + LessThan ComparisonOperator = "<" + GreaterOrEqual ComparisonOperator = ">=" + LessOrEqual ComparisonOperator = "<=" + Equal ComparisonOperator = "==" + NotEqual ComparisonOperator = "!=" + Is ComparisonOperator = "is" + + And LogicalOperator = "and" + Or LogicalOperator = "or" + Not LogicalOperator = "not" +) + +type Expr interface { + GetType() string + Eval(variables map[string]map[string]any) (any, error) +} + +type ConstExpr struct { + Type string `json:"type"` + Value Value `json:"value"` +} + +func (c ConstExpr) GetType() string { return c.Type } + +type VarExpr struct { + Type string `json:"type"` + Selector WorkflowNodeIOValueSelector `json:"selector"` +} + +func (v VarExpr) GetType() string { return v.Type } + +func (v VarExpr) Eval(variables map[string]map[string]any) (any, error) { + if v.Selector.Id == "" { + return nil, fmt.Errorf("node id is empty") + } + if v.Selector.Name == "" { + return nil, fmt.Errorf("name is empty") + } + + if _, ok := variables[v.Selector.Id]; !ok { + return nil, fmt.Errorf("node %s not found", v.Selector.Id) + } + + if _, ok := variables[v.Selector.Id][v.Selector.Name]; !ok { + return nil, fmt.Errorf("variable %s not found in node %s", v.Selector.Name, v.Selector.NodeId) + } + + return variables[v.Selector.Id][v.Selector.Name], nil +} + +type CompareExpr struct { + Type string `json:"type"` // compare + Op ComparisonOperator `json:"op"` + Left Expr `json:"left"` + Right Expr `json:"right"` +} + +func (c CompareExpr) GetType() string { return c.Type } + +func (c CompareExpr) Eval(variables map[string]map[string]any) (any, error) { + left, err := c.Left.Eval(variables) + if err != nil { + return nil, err + } + right, err := c.Right.Eval(variables) + if err != nil { + return nil, err + } + + switch c.Op { + case GreaterThan: + return left.(float64) > right.(float64), nil + case LessThan: + return left.(float64) < right.(float64), nil + case GreaterOrEqual: + return left.(float64) >= right.(float64), nil + case LessOrEqual: + return left.(float64) <= right.(float64), nil + case Equal: + return left == right, nil + case NotEqual: + return left != right, nil + case Is: + return left == right, nil + default: + return nil, fmt.Errorf("unknown operator: %s", c.Op) + } +} + +type LogicalExpr struct { + Type string `json:"type"` // logical + Op LogicalOperator `json:"op"` + Left Expr `json:"left"` + Right Expr `json:"right"` +} + +func (l LogicalExpr) GetType() string { return l.Type } + +func (l LogicalExpr) Eval(variables map[string]map[string]any) (any, error) { + left, err := l.Left.Eval(variables) + if err != nil { + return nil, err + } + right, err := l.Right.Eval(variables) + if err != nil { + return nil, err + } + + switch l.Op { + case And: + return left.(bool) && right.(bool), nil + case Or: + return left.(bool) || right.(bool), nil + default: + return nil, fmt.Errorf("unknown operator: %s", l.Op) + } +} + +type NotExpr struct { + Type string `json:"type"` // not + Expr Expr `json:"expr"` +} + +func (n NotExpr) GetType() string { return n.Type } + +func (n NotExpr) Eval(variables map[string]map[string]any) (any, error) { + inner, err := n.Expr.Eval(variables) + if err != nil { + return nil, err + } + return !inner.(bool), nil +} + +type rawExpr struct { + Type string `json:"type"` +} + +func MarshalExpr(e Expr) ([]byte, error) { + return json.Marshal(e) +} + +func UnmarshalExpr(data []byte) (Expr, error) { + var typ rawExpr + if err := json.Unmarshal(data, &typ); err != nil { + return nil, err + } + + switch typ.Type { + case "const": + var e ConstExpr + if err := json.Unmarshal(data, &e); err != nil { + return nil, err + } + return e, nil + case "var": + var e VarExpr + if err := json.Unmarshal(data, &e); err != nil { + return nil, err + } + return e, nil + case "compare": + var e CompareExprRaw + if err := json.Unmarshal(data, &e); err != nil { + return nil, err + } + return e.ToCompareExpr() + case "logical": + var e LogicalExprRaw + if err := json.Unmarshal(data, &e); err != nil { + return nil, err + } + return e.ToLogicalExpr() + case "not": + var e NotExprRaw + if err := json.Unmarshal(data, &e); err != nil { + return nil, err + } + return e.ToNotExpr() + default: + return nil, fmt.Errorf("unknown expr type: %s", typ.Type) + } +} + +type CompareExprRaw struct { + Type string `json:"type"` + Op ComparisonOperator `json:"op"` + Left json.RawMessage `json:"left"` + Right json.RawMessage `json:"right"` +} + +func (r CompareExprRaw) ToCompareExpr() (CompareExpr, error) { + leftExpr, err := UnmarshalExpr(r.Left) + if err != nil { + return CompareExpr{}, err + } + rightExpr, err := UnmarshalExpr(r.Right) + if err != nil { + return CompareExpr{}, err + } + return CompareExpr{ + Type: r.Type, + Op: r.Op, + Left: leftExpr, + Right: rightExpr, + }, nil +} + +type LogicalExprRaw struct { + Type string `json:"type"` + Op LogicalOperator `json:"op"` + Left json.RawMessage `json:"left"` + Right json.RawMessage `json:"right"` +} + +func (r LogicalExprRaw) ToLogicalExpr() (LogicalExpr, error) { + left, err := UnmarshalExpr(r.Left) + if err != nil { + return LogicalExpr{}, err + } + right, err := UnmarshalExpr(r.Right) + if err != nil { + return LogicalExpr{}, err + } + return LogicalExpr{ + Type: r.Type, + Op: r.Op, + Left: left, + Right: right, + }, nil +} + +type NotExprRaw struct { + Type string `json:"type"` + Expr json.RawMessage `json:"expr"` +} + +func (r NotExprRaw) ToNotExpr() (NotExpr, error) { + inner, err := UnmarshalExpr(r.Expr) + if err != nil { + return NotExpr{}, err + } + return NotExpr{ + Type: r.Type, + Expr: inner, + }, nil +} diff --git a/internal/domain/workflow.go b/internal/domain/workflow.go index 6f3cccea..e1e72354 100644 --- a/internal/domain/workflow.go +++ b/internal/domain/workflow.go @@ -81,6 +81,10 @@ type WorkflowNodeConfigForApply struct { SkipBeforeExpiryDays int32 `json:"skipBeforeExpiryDays,omitempty"` // 证书到期前多少天前跳过续期(零值将使用默认值 30) } +type WorkflowNodeConfigForCondition struct { + Expression Expr `json:"expression"` // 条件表达式 +} + type WorkflowNodeConfigForUpload struct { Certificate string `json:"certificate"` PrivateKey string `json:"privateKey"` @@ -104,6 +108,22 @@ type WorkflowNodeConfigForNotify struct { Message string `json:"message"` // 通知内容 } +func (n *WorkflowNode) GetConfigForCondition() WorkflowNodeConfigForCondition { + raw := maputil.GetString(n.Config, "expression") + if raw == "" { + return WorkflowNodeConfigForCondition{} + } + + expr, err := UnmarshalExpr([]byte(raw)) + if err != nil { + return WorkflowNodeConfigForCondition{} + } + + return WorkflowNodeConfigForCondition{ + Expression: expr, + } +} + func (n *WorkflowNode) GetConfigForApply() WorkflowNodeConfigForApply { skipBeforeExpiryDays := maputil.GetInt32(n.Config, "skipBeforeExpiryDays") if skipBeforeExpiryDays == 0 { @@ -171,6 +191,7 @@ type WorkflowNodeIO struct { type WorkflowNodeIOValueSelector struct { Id string `json:"id"` Name string `json:"name"` + Type string `json:"type"` } const WorkflowNodeIONameCertificate string = "certificate" diff --git a/internal/workflow/dispatcher/invoker.go b/internal/workflow/dispatcher/invoker.go index c644b26b..a4de08e7 100644 --- a/internal/workflow/dispatcher/invoker.go +++ b/internal/workflow/dispatcher/invoker.go @@ -101,6 +101,11 @@ func (w *workflowInvoker) processNode(ctx context.Context, node *domain.Workflow processor.GetLogger().Error(procErr.Error()) break } + + nodeOutputs := processor.GetOutputs() + if len(nodeOutputs) > 0 { + ctx = nodes.AddNodeOutput(ctx, current.Id, nodeOutputs) + } } break diff --git a/internal/workflow/node-processor/apply_node.go b/internal/workflow/node-processor/apply_node.go index 97b7575d..468f553b 100644 --- a/internal/workflow/node-processor/apply_node.go +++ b/internal/workflow/node-processor/apply_node.go @@ -16,6 +16,7 @@ import ( type applyNode struct { node *domain.WorkflowNode *nodeProcessor + *nodeOutputer certRepo certificateRepository outputRepo workflowOutputRepository @@ -25,6 +26,7 @@ func NewApplyNode(node *domain.WorkflowNode) *applyNode { return &applyNode{ node: node, nodeProcessor: newNodeProcessor(node), + nodeOutputer: newNodeOutputer(), certRepo: repository.NewCertificateRepository(), outputRepo: repository.NewWorkflowOutputRepository(), @@ -71,6 +73,7 @@ func (n *applyNode) Process(ctx context.Context) error { n.logger.Warn("failed to parse certificate, may be the CA responded error") return err } + certificate := &domain.Certificate{ Source: domain.CertificateSourceTypeWorkflow, Certificate: applyResult.CertificateFullChain, @@ -96,6 +99,10 @@ func (n *applyNode) Process(ctx context.Context) error { return err } + // 添加中间结果 + n.outputs["certificate.validated"] = true + n.outputs["certificate.daysLeft"] = int(time.Until(certificate.ExpireAt).Hours() / 24) + n.logger.Info("apply completed") return nil @@ -139,6 +146,10 @@ func (n *applyNode) checkCanSkip(ctx context.Context, lastOutput *domain.Workflo renewalInterval := time.Duration(currentNodeConfig.SkipBeforeExpiryDays) * time.Hour * 24 expirationTime := time.Until(lastCertificate.ExpireAt) if expirationTime > renewalInterval { + + n.outputs["certificate.validated"] = true + n.outputs["certificate.daysLeft"] = int(expirationTime.Hours() / 24) + return true, fmt.Sprintf("the certificate has already been issued (expires in %dd, next renewal in %dd)", int(expirationTime.Hours()/24), currentNodeConfig.SkipBeforeExpiryDays) } } diff --git a/internal/workflow/node-processor/condition_node.go b/internal/workflow/node-processor/condition_node.go index 2bac55fa..f8ed228b 100644 --- a/internal/workflow/node-processor/condition_node.go +++ b/internal/workflow/node-processor/condition_node.go @@ -2,6 +2,7 @@ package nodeprocessor import ( "context" + "errors" "github.com/usual2970/certimate/internal/domain" ) @@ -9,16 +10,43 @@ import ( type conditionNode struct { node *domain.WorkflowNode *nodeProcessor + *nodeOutputer } func NewConditionNode(node *domain.WorkflowNode) *conditionNode { return &conditionNode{ node: node, nodeProcessor: newNodeProcessor(node), + nodeOutputer: newNodeOutputer(), } } func (n *conditionNode) Process(ctx context.Context) error { - // 此类型节点不需要执行任何操作,直接返回 + n.logger.Info("enter condition node: " + n.node.Name) + + nodeConfig := n.node.GetConfigForCondition() + if nodeConfig.Expression == nil { + return nil + } return nil } + +func (n *conditionNode) eval(ctx context.Context, expression domain.Expr) (any, error) { + switch expr:=expression.(type) { + case domain.CompareExpr: + left,err:= n.eval(ctx, expr.Left) + if err != nil { + return nil, err + } + right,err:= n.eval(ctx, expr.Right) + if err != nil { + return nil, err + } + + case domain.LogicalExpr: + case domain.NotExpr: + case domain.VarExpr: + case domain.ConstExpr: + } + return false, errors.New("unknown expression type") +} diff --git a/internal/workflow/node-processor/context.go b/internal/workflow/node-processor/context.go new file mode 100644 index 00000000..adceacf6 --- /dev/null +++ b/internal/workflow/node-processor/context.go @@ -0,0 +1,126 @@ +package nodeprocessor + +import ( + "context" + "sync" +) + +// 定义上下文键类型,避免键冲突 +type workflowContextKey string + +const ( + nodeOutputsKey workflowContextKey = "node_outputs" +) + +// 带互斥锁的节点输出容器 +type nodeOutputsContainer struct { + sync.RWMutex + outputs map[string]map[string]any +} + +// 创建新的并发安全的节点输出容器 +func newNodeOutputsContainer() *nodeOutputsContainer { + return &nodeOutputsContainer{ + outputs: make(map[string]map[string]any), + } +} + +// 添加节点输出到上下文 +func AddNodeOutput(ctx context.Context, nodeId string, output map[string]any) context.Context { + container := getNodeOutputsContainer(ctx) + if container == nil { + container = newNodeOutputsContainer() + } + + container.Lock() + defer container.Unlock() + + // 创建输出的深拷贝以避免后续修改 + outputCopy := make(map[string]any, len(output)) + for k, v := range output { + outputCopy[k] = v + } + + container.outputs[nodeId] = outputCopy + return context.WithValue(ctx, nodeOutputsKey, container) +} + +// 从上下文获取节点输出 +func GetNodeOutput(ctx context.Context, nodeId string) map[string]any { + container := getNodeOutputsContainer(ctx) + if container == nil { + return nil + } + + container.RLock() + defer container.RUnlock() + + output, exists := container.outputs[nodeId] + if !exists { + return nil + } + + outputCopy := make(map[string]any, len(output)) + for k, v := range output { + outputCopy[k] = v + } + + return outputCopy +} + +// 获取特定节点的特定输出项 +func GetNodeOutputValue(ctx context.Context, nodeId string, key string) (any, bool) { + output := GetNodeOutput(ctx, nodeId) + if output == nil { + return nil, false + } + + value, exists := output[key] + return value, exists +} + +// 获取所有节点输出 +func GetNodeOutputs(ctx context.Context) map[string]map[string]any { + container := getNodeOutputsContainer(ctx) + if container == nil { + return nil + } + + container.RLock() + defer container.RUnlock() + + // 创建所有输出的深拷贝 + allOutputs := make(map[string]map[string]any, len(container.outputs)) + for nodeId, output := range container.outputs { + nodeCopy := make(map[string]any, len(output)) + for k, v := range output { + nodeCopy[k] = v + } + allOutputs[nodeId] = nodeCopy + } + + return allOutputs +} + +// 获取节点输出容器 +func getNodeOutputsContainer(ctx context.Context) *nodeOutputsContainer { + value := ctx.Value(nodeOutputsKey) + if value == nil { + return nil + } + return value.(*nodeOutputsContainer) +} + +// 检查节点是否有输出 +func HasNodeOutput(ctx context.Context, nodeId string) bool { + container := getNodeOutputsContainer(ctx) + if container == nil { + return false + } + + container.RLock() + defer container.RUnlock() + + _, exists := container.outputs[nodeId] + return exists +} diff --git a/internal/workflow/node-processor/deploy_node.go b/internal/workflow/node-processor/deploy_node.go index d60a5a7a..3819b4a2 100644 --- a/internal/workflow/node-processor/deploy_node.go +++ b/internal/workflow/node-processor/deploy_node.go @@ -15,6 +15,7 @@ import ( type deployNode struct { node *domain.WorkflowNode *nodeProcessor + *nodeOutputer certRepo certificateRepository outputRepo workflowOutputRepository @@ -24,6 +25,7 @@ func NewDeployNode(node *domain.WorkflowNode) *deployNode { return &deployNode{ node: node, nodeProcessor: newNodeProcessor(node), + nodeOutputer: newNodeOutputer(), certRepo: repository.NewCertificateRepository(), outputRepo: repository.NewWorkflowOutputRepository(), diff --git a/internal/workflow/node-processor/execute_failure_node.go b/internal/workflow/node-processor/execute_failure_node.go index 59f6a5bd..d3f61e30 100644 --- a/internal/workflow/node-processor/execute_failure_node.go +++ b/internal/workflow/node-processor/execute_failure_node.go @@ -9,12 +9,14 @@ import ( type executeFailureNode struct { node *domain.WorkflowNode *nodeProcessor + *nodeOutputer } func NewExecuteFailureNode(node *domain.WorkflowNode) *executeFailureNode { return &executeFailureNode{ node: node, nodeProcessor: newNodeProcessor(node), + nodeOutputer: newNodeOutputer(), } } diff --git a/internal/workflow/node-processor/execute_success_node.go b/internal/workflow/node-processor/execute_success_node.go index e5b65860..46a74482 100644 --- a/internal/workflow/node-processor/execute_success_node.go +++ b/internal/workflow/node-processor/execute_success_node.go @@ -9,12 +9,14 @@ import ( type executeSuccessNode struct { node *domain.WorkflowNode *nodeProcessor + *nodeOutputer } func NewExecuteSuccessNode(node *domain.WorkflowNode) *executeSuccessNode { return &executeSuccessNode{ node: node, nodeProcessor: newNodeProcessor(node), + nodeOutputer: newNodeOutputer(), } } diff --git a/internal/workflow/node-processor/notify_node.go b/internal/workflow/node-processor/notify_node.go index 1840938b..8f336931 100644 --- a/internal/workflow/node-processor/notify_node.go +++ b/internal/workflow/node-processor/notify_node.go @@ -12,6 +12,7 @@ import ( type notifyNode struct { node *domain.WorkflowNode *nodeProcessor + *nodeOutputer settingsRepo settingsRepository } @@ -20,6 +21,7 @@ func NewNotifyNode(node *domain.WorkflowNode) *notifyNode { return ¬ifyNode{ node: node, nodeProcessor: newNodeProcessor(node), + nodeOutputer: newNodeOutputer(), settingsRepo: repository.NewSettingsRepository(), } diff --git a/internal/workflow/node-processor/processor.go b/internal/workflow/node-processor/processor.go index 4523b13a..eb7bc155 100644 --- a/internal/workflow/node-processor/processor.go +++ b/internal/workflow/node-processor/processor.go @@ -14,6 +14,8 @@ type NodeProcessor interface { SetLogger(*slog.Logger) Process(ctx context.Context) error + + GetOutputs() map[string]any } type nodeProcessor struct { @@ -32,6 +34,20 @@ func (n *nodeProcessor) SetLogger(logger *slog.Logger) { n.logger = logger } +type nodeOutputer struct { + outputs map[string]any +} + +func newNodeOutputer() *nodeOutputer { + return &nodeOutputer{ + outputs: make(map[string]any), + } +} + +func (n *nodeOutputer) GetOutputs() map[string]any { + return n.outputs +} + type certificateRepository interface { GetByWorkflowNodeId(ctx context.Context, workflowNodeId string) (*domain.Certificate, error) } diff --git a/internal/workflow/node-processor/start_node.go b/internal/workflow/node-processor/start_node.go index 5bbc1c09..30dee424 100644 --- a/internal/workflow/node-processor/start_node.go +++ b/internal/workflow/node-processor/start_node.go @@ -9,12 +9,14 @@ import ( type startNode struct { node *domain.WorkflowNode *nodeProcessor + *nodeOutputer } func NewStartNode(node *domain.WorkflowNode) *startNode { return &startNode{ node: node, nodeProcessor: newNodeProcessor(node), + nodeOutputer: newNodeOutputer(), } } diff --git a/internal/workflow/node-processor/upload_node.go b/internal/workflow/node-processor/upload_node.go index 2da19eed..7fbb1515 100644 --- a/internal/workflow/node-processor/upload_node.go +++ b/internal/workflow/node-processor/upload_node.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "time" "github.com/usual2970/certimate/internal/domain" "github.com/usual2970/certimate/internal/repository" @@ -12,6 +13,7 @@ import ( type uploadNode struct { node *domain.WorkflowNode *nodeProcessor + *nodeOutputer certRepo certificateRepository outputRepo workflowOutputRepository @@ -21,6 +23,7 @@ func NewUploadNode(node *domain.WorkflowNode) *uploadNode { return &uploadNode{ node: node, nodeProcessor: newNodeProcessor(node), + nodeOutputer: newNodeOutputer(), certRepo: repository.NewCertificateRepository(), outputRepo: repository.NewWorkflowOutputRepository(), @@ -66,6 +69,9 @@ func (n *uploadNode) Process(ctx context.Context) error { return err } + n.outputs["certificate.validated"] = true + n.outputs["certificate.daysLeft"] = int(time.Until(certificate.ExpireAt).Hours() / 24) + n.logger.Info("upload completed") return nil @@ -85,6 +91,8 @@ func (n *uploadNode) checkCanSkip(ctx context.Context, lastOutput *domain.Workfl lastCertificate, _ := n.certRepo.GetByWorkflowNodeId(ctx, n.node.Id) if lastCertificate != nil { + n.outputs["certificate.validated"] = true + n.outputs["certificate.daysLeft"] = int(time.Until(lastCertificate.ExpireAt).Hours() / 24) return true, "the certificate has already been uploaded" } } diff --git a/ui/src/components/workflow/node/ConditionNode.tsx b/ui/src/components/workflow/node/ConditionNode.tsx index 43b32e60..d3f1defc 100644 --- a/ui/src/components/workflow/node/ConditionNode.tsx +++ b/ui/src/components/workflow/node/ConditionNode.tsx @@ -5,7 +5,7 @@ import { Button, Card, Popover } from "antd"; import SharedNode, { type SharedNodeProps } from "./_SharedNode"; import AddNode from "./AddNode"; import ConditionNodeConfigForm, { ConditionItem, ConditionNodeConfigFormFieldValues, ConditionNodeConfigFormInstance } from "./ConditionNodeConfigForm"; -import { Expr, WorkflowNodeIoValueType } from "@/domain/workflow"; +import { Expr, WorkflowNodeIoValueType, Value } from "@/domain/workflow"; import { produce } from "immer"; import { useWorkflowStore } from "@/stores/workflow"; import { useZustandShallowSelector } from "@/hooks"; @@ -30,15 +30,34 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP // 创建单个条件的表达式 const createComparisonExpr = (condition: ConditionItem): Expr => { const selectors = condition.leftSelector.split("#"); + const t = selectors[2] as WorkflowNodeIoValueType; const left: Expr = { type: "var", selector: { id: selectors[0], name: selectors[1], - type: selectors[2] as WorkflowNodeIoValueType, + type: t, }, }; - const right: Expr = { type: "const", value: condition.rightValue || "" }; + + let value: Value = condition.rightValue; + switch (t) { + case "boolean": + if (value === "true") { + value = true; + } else if (value === "false") { + value = false; + } + break; + case "number": + value = parseInt(value as string); + break; + case "string": + value = value as string; + break; + } + + const right: Expr = { type: "const", value: value }; return { type: "compare", diff --git a/ui/src/domain/workflow.ts b/ui/src/domain/workflow.ts index 792b7d45..9cd12287 100644 --- a/ui/src/domain/workflow.ts +++ b/ui/src/domain/workflow.ts @@ -232,7 +232,7 @@ export const workflowNodeIOOptions = (node: WorkflowNode) => { // #region Condition expression -type Value = string | number | boolean; +export type Value = string | number | boolean; export type ComparisonOperator = ">" | "<" | ">=" | "<=" | "==" | "!=" | "is"; From faad7cb6d791ff59a2def22bdefb9c2422d62f49 Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Tue, 20 May 2025 22:54:41 +0800 Subject: [PATCH 08/14] improve condition evaluate --- internal/domain/expr.go | 300 ++++++++++++++++-- internal/domain/expr_test.go | 127 ++++++++ internal/domain/workflow.go | 7 +- internal/workflow/dispatcher/invoker.go | 11 +- .../workflow/node-processor/condition_node.go | 35 +- .../workflow/node/ConditionNode.tsx | 2 +- .../workflow/node/ConditionNodeConfigForm.tsx | 1 + ui/src/domain/workflow.ts | 2 +- 8 files changed, 440 insertions(+), 45 deletions(-) create mode 100644 internal/domain/expr_test.go diff --git a/internal/domain/expr.go b/internal/domain/expr.go index 3b312642..4791ba7d 100644 --- a/internal/domain/expr.go +++ b/internal/domain/expr.go @@ -26,18 +26,276 @@ const ( Not LogicalOperator = "not" ) +type EvalResult struct { + Type string + Value any +} + +func (e *EvalResult) GetFloat64() (float64, error) { + if e.Type != "number" { + return 0, fmt.Errorf("type mismatch: %s", e.Type) + } + switch v := e.Value.(type) { + case int: + return float64(v), nil + case float64: + return v, nil + default: + return 0, fmt.Errorf("unsupported type: %T", v) + } +} + +func (e *EvalResult) GreaterThan(other *EvalResult) (*EvalResult, error) { + if e.Type != other.Type { + return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) + } + switch e.Type { + case "number": + + left, err := e.GetFloat64() + if err != nil { + return nil, err + } + right, err := other.GetFloat64() + if err != nil { + return nil, err + } + + return &EvalResult{ + Type: "boolean", + Value: left > right, + }, nil + case "string": + return &EvalResult{ + Type: "boolean", + Value: e.Value.(string) > other.Value.(string), + }, nil + + default: + return nil, fmt.Errorf("unsupported type: %s", e.Type) + } +} + +func (e *EvalResult) GreaterOrEqual(other *EvalResult) (*EvalResult, error) { + if e.Type != other.Type { + return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) + } + switch e.Type { + case "number": + left, err := e.GetFloat64() + if err != nil { + return nil, err + } + right, err := other.GetFloat64() + if err != nil { + return nil, err + } + return &EvalResult{ + Type: "boolean", + Value: left >= right, + }, nil + case "string": + return &EvalResult{ + Type: "boolean", + Value: e.Value.(string) >= other.Value.(string), + }, nil + + default: + return nil, fmt.Errorf("unsupported type: %s", e.Type) + } +} + +func (e *EvalResult) LessThan(other *EvalResult) (*EvalResult, error) { + if e.Type != other.Type { + return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) + } + switch e.Type { + case "number": + left, err := e.GetFloat64() + if err != nil { + return nil, err + } + right, err := other.GetFloat64() + if err != nil { + return nil, err + } + return &EvalResult{ + Type: "boolean", + Value: left < right, + }, nil + case "string": + return &EvalResult{ + Type: "boolean", + Value: e.Value.(string) < other.Value.(string), + }, nil + + default: + return nil, fmt.Errorf("unsupported type: %s", e.Type) + } +} + +func (e *EvalResult) LessOrEqual(other *EvalResult) (*EvalResult, error) { + if e.Type != other.Type { + return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) + } + switch e.Type { + case "number": + left, err := e.GetFloat64() + if err != nil { + return nil, err + } + right, err := other.GetFloat64() + if err != nil { + return nil, err + } + return &EvalResult{ + Type: "boolean", + Value: left <= right, + }, nil + case "string": + return &EvalResult{ + Type: "boolean", + Value: e.Value.(string) <= other.Value.(string), + }, nil + + default: + return nil, fmt.Errorf("unsupported type: %s", e.Type) + } +} + +func (e *EvalResult) Equal(other *EvalResult) (*EvalResult, error) { + if e.Type != other.Type { + return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) + } + switch e.Type { + case "number": + left, err := e.GetFloat64() + if err != nil { + return nil, err + } + right, err := other.GetFloat64() + if err != nil { + return nil, err + } + return &EvalResult{ + Type: "boolean", + Value: left == right, + }, nil + case "string": + return &EvalResult{ + Type: "boolean", + Value: e.Value.(string) == other.Value.(string), + }, nil + + default: + return nil, fmt.Errorf("unsupported type: %s", e.Type) + } +} + +func (e *EvalResult) NotEqual(other *EvalResult) (*EvalResult, error) { + if e.Type != other.Type { + return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) + } + switch e.Type { + case "number": + left, err := e.GetFloat64() + if err != nil { + return nil, err + } + right, err := other.GetFloat64() + if err != nil { + return nil, err + } + return &EvalResult{ + Type: "boolean", + Value: left != right, + }, nil + case "string": + return &EvalResult{ + Type: "boolean", + Value: e.Value.(string) != other.Value.(string), + }, nil + + default: + return nil, fmt.Errorf("unsupported type: %s", e.Type) + } +} + +func (e *EvalResult) And(other *EvalResult) (*EvalResult, error) { + if e.Type != other.Type { + return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) + } + switch e.Type { + case "boolean": + return &EvalResult{ + Type: "boolean", + Value: e.Value.(bool) && other.Value.(bool), + }, nil + default: + return nil, fmt.Errorf("unsupported type: %s", e.Type) + } +} + +func (e *EvalResult) Or(other *EvalResult) (*EvalResult, error) { + if e.Type != other.Type { + return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) + } + switch e.Type { + case "boolean": + return &EvalResult{ + Type: "boolean", + Value: e.Value.(bool) || other.Value.(bool), + }, nil + default: + return nil, fmt.Errorf("unsupported type: %s", e.Type) + } +} + +func (e *EvalResult) Not() (*EvalResult, error) { + if e.Type != "boolean" { + return nil, fmt.Errorf("type mismatch: %s", e.Type) + } + return &EvalResult{ + Type: "boolean", + Value: !e.Value.(bool), + }, nil +} + +func (e *EvalResult) Is(other *EvalResult) (*EvalResult, error) { + if e.Type != other.Type { + return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) + } + switch e.Type { + case "boolean": + return &EvalResult{ + Type: "boolean", + Value: e.Value.(bool) == other.Value.(bool), + }, nil + default: + return nil, fmt.Errorf("unsupported type: %s", e.Type) + } +} + type Expr interface { GetType() string - Eval(variables map[string]map[string]any) (any, error) + Eval(variables map[string]map[string]any) (*EvalResult, error) } type ConstExpr struct { - Type string `json:"type"` - Value Value `json:"value"` + Type string `json:"type"` + Value Value `json:"value"` + ValueType string `json:"valueType"` } func (c ConstExpr) GetType() string { return c.Type } +func (c ConstExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) { + return &EvalResult{ + Type: c.ValueType, + Value: c.Value, + }, nil +} + type VarExpr struct { Type string `json:"type"` Selector WorkflowNodeIOValueSelector `json:"selector"` @@ -45,7 +303,7 @@ type VarExpr struct { func (v VarExpr) GetType() string { return v.Type } -func (v VarExpr) Eval(variables map[string]map[string]any) (any, error) { +func (v VarExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) { if v.Selector.Id == "" { return nil, fmt.Errorf("node id is empty") } @@ -58,10 +316,12 @@ func (v VarExpr) Eval(variables map[string]map[string]any) (any, error) { } if _, ok := variables[v.Selector.Id][v.Selector.Name]; !ok { - return nil, fmt.Errorf("variable %s not found in node %s", v.Selector.Name, v.Selector.NodeId) + return nil, fmt.Errorf("variable %s not found in node %s", v.Selector.Name, v.Selector.Id) } - - return variables[v.Selector.Id][v.Selector.Name], nil + return &EvalResult{ + Type: v.Selector.Type, + Value: variables[v.Selector.Id][v.Selector.Name], + }, nil } type CompareExpr struct { @@ -73,7 +333,7 @@ type CompareExpr struct { func (c CompareExpr) GetType() string { return c.Type } -func (c CompareExpr) Eval(variables map[string]map[string]any) (any, error) { +func (c CompareExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) { left, err := c.Left.Eval(variables) if err != nil { return nil, err @@ -85,19 +345,19 @@ func (c CompareExpr) Eval(variables map[string]map[string]any) (any, error) { switch c.Op { case GreaterThan: - return left.(float64) > right.(float64), nil + return left.GreaterThan(right) case LessThan: - return left.(float64) < right.(float64), nil + return left.LessThan(right) case GreaterOrEqual: - return left.(float64) >= right.(float64), nil + return left.GreaterOrEqual(right) case LessOrEqual: - return left.(float64) <= right.(float64), nil + return left.LessOrEqual(right) case Equal: - return left == right, nil + return left.Equal(right) case NotEqual: - return left != right, nil + return left.NotEqual(right) case Is: - return left == right, nil + return left.Is(right) default: return nil, fmt.Errorf("unknown operator: %s", c.Op) } @@ -112,7 +372,7 @@ type LogicalExpr struct { func (l LogicalExpr) GetType() string { return l.Type } -func (l LogicalExpr) Eval(variables map[string]map[string]any) (any, error) { +func (l LogicalExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) { left, err := l.Left.Eval(variables) if err != nil { return nil, err @@ -124,9 +384,9 @@ func (l LogicalExpr) Eval(variables map[string]map[string]any) (any, error) { switch l.Op { case And: - return left.(bool) && right.(bool), nil + return left.And(right) case Or: - return left.(bool) || right.(bool), nil + return left.Or(right) default: return nil, fmt.Errorf("unknown operator: %s", l.Op) } @@ -139,12 +399,12 @@ type NotExpr struct { func (n NotExpr) GetType() string { return n.Type } -func (n NotExpr) Eval(variables map[string]map[string]any) (any, error) { +func (n NotExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) { inner, err := n.Expr.Eval(variables) if err != nil { return nil, err } - return !inner.(bool), nil + return inner.Not() } type rawExpr struct { diff --git a/internal/domain/expr_test.go b/internal/domain/expr_test.go new file mode 100644 index 00000000..f0a34504 --- /dev/null +++ b/internal/domain/expr_test.go @@ -0,0 +1,127 @@ +package domain + +import ( + "testing" +) + +func TestLogicalEval(t *testing.T) { + // 测试逻辑表达式 and + logicalExpr := LogicalExpr{ + Left: ConstExpr{ + Type: "const", + Value: true, + ValueType: "boolean", + }, + Op: And, + Right: ConstExpr{ + Type: "const", + Value: true, + ValueType: "boolean", + }, + } + result, err := logicalExpr.Eval(nil) + if err != nil { + t.Errorf("failed to evaluate logical expression: %v", err) + } + if result.Value != true { + t.Errorf("expected true, got %v", result) + } + + // 测试逻辑表达式 or + orExpr := LogicalExpr{ + Left: ConstExpr{ + Type: "const", + Value: true, + ValueType: "boolean", + }, + Op: Or, + Right: ConstExpr{ + Type: "const", + Value: true, + ValueType: "boolean", + }, + } + result, err = orExpr.Eval(nil) + if err != nil { + t.Errorf("failed to evaluate logical expression: %v", err) + } + if result.Value != true { + t.Errorf("expected true, got %v", result) + } +} + +func TestUnmarshalExpr(t *testing.T) { + type args struct { + data []byte + } + tests := []struct { + name string + args args + want Expr + wantErr bool + }{ + { + name: "test1", + args: args{ + data: []byte(`{"left":{"left":{"selector":{"id":"ODnYSOXB6HQP2_vz6JcZE","name":"certificate.validated","type":"boolean"},"type":"var"},"op":"is","right":{"type":"const","value":true,"valueType":"boolean"},"type":"compare"},"op":"and","right":{"left":{"selector":{"id":"ODnYSOXB6HQP2_vz6JcZE","name":"certificate.daysLeft","type":"number"},"type":"var"},"op":"==","right":{"type":"const","value":2,"valueType":"number"},"type":"compare"},"type":"logical"}`), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := UnmarshalExpr(tt.args.data) + if (err != nil) != tt.wantErr { + t.Errorf("UnmarshalExpr() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got == nil { + t.Errorf("UnmarshalExpr() got = nil, want %v", tt.want) + return + } + }) + } +} + +func TestExpr_Eval(t *testing.T) { + type args struct { + variables map[string]map[string]any + data []byte + } + tests := []struct { + name string + args args + want *EvalResult + wantErr bool + }{ + { + name: "test1", + args: args{ + variables: map[string]map[string]any{ + "ODnYSOXB6HQP2_vz6JcZE": { + "certificate.validated": true, + "certificate.daysLeft": 2, + }, + }, + data: []byte(`{"left":{"left":{"selector":{"id":"ODnYSOXB6HQP2_vz6JcZE","name":"certificate.validated","type":"boolean"},"type":"var"},"op":"is","right":{"type":"const","value":true,"valueType":"boolean"},"type":"compare"},"op":"and","right":{"left":{"selector":{"id":"ODnYSOXB6HQP2_vz6JcZE","name":"certificate.daysLeft","type":"number"},"type":"var"},"op":"==","right":{"type":"const","value":2,"valueType":"number"},"type":"compare"},"type":"logical"}`), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, err := UnmarshalExpr(tt.args.data) + if err != nil { + t.Errorf("UnmarshalExpr() error = %v", err) + return + } + got, err := c.Eval(tt.args.variables) + t.Log("got:", got) + if (err != nil) != tt.wantErr { + t.Errorf("ConstExpr.Eval() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got.Value != true { + t.Errorf("ConstExpr.Eval() got = %v, want %v", got.Value, true) + } + }) + } +} diff --git a/internal/domain/workflow.go b/internal/domain/workflow.go index e1e72354..63237221 100644 --- a/internal/domain/workflow.go +++ b/internal/domain/workflow.go @@ -1,6 +1,7 @@ package domain import ( + "encoding/json" "time" maputil "github.com/usual2970/certimate/internal/pkg/utils/map" @@ -109,11 +110,13 @@ type WorkflowNodeConfigForNotify struct { } func (n *WorkflowNode) GetConfigForCondition() WorkflowNodeConfigForCondition { - raw := maputil.GetString(n.Config, "expression") - if raw == "" { + expression := n.Config["expression"] + if expression == nil { return WorkflowNodeConfigForCondition{} } + raw, _ := json.Marshal(expression) + expr, err := UnmarshalExpr([]byte(raw)) if err != nil { return WorkflowNodeConfigForCondition{} diff --git a/internal/workflow/dispatcher/invoker.go b/internal/workflow/dispatcher/invoker.go index a4de08e7..b6e4a4db 100644 --- a/internal/workflow/dispatcher/invoker.go +++ b/internal/workflow/dispatcher/invoker.go @@ -98,7 +98,9 @@ func (w *workflowInvoker) processNode(ctx context.Context, node *domain.Workflow procErr = processor.Process(ctx) if procErr != nil { - processor.GetLogger().Error(procErr.Error()) + if current.Type != domain.WorkflowNodeTypeCondition { + processor.GetLogger().Error(procErr.Error()) + } break } @@ -110,9 +112,12 @@ func (w *workflowInvoker) processNode(ctx context.Context, node *domain.Workflow break } - // TODO: 优化可读性 - if procErr != nil && current.Next != nil && current.Next.Type != domain.WorkflowNodeTypeExecuteResultBranch { + if procErr != nil && current.Type == domain.WorkflowNodeTypeCondition { + current = nil + procErr = nil + return nil + } else if procErr != nil && current.Next != nil && current.Next.Type != domain.WorkflowNodeTypeExecuteResultBranch { return procErr } else if procErr != nil && current.Next != nil && current.Next.Type == domain.WorkflowNodeTypeExecuteResultBranch { current = w.getBranchByType(current.Next.Branches, domain.WorkflowNodeTypeExecuteFailure) diff --git a/internal/workflow/node-processor/condition_node.go b/internal/workflow/node-processor/condition_node.go index f8ed228b..d90811d9 100644 --- a/internal/workflow/node-processor/condition_node.go +++ b/internal/workflow/node-processor/condition_node.go @@ -26,27 +26,26 @@ func (n *conditionNode) Process(ctx context.Context) error { nodeConfig := n.node.GetConfigForCondition() if nodeConfig.Expression == nil { + n.logger.Info("no condition found, continue to next node") return nil } + + rs, err := n.eval(ctx, nodeConfig.Expression) + if err != nil { + n.logger.Warn("failed to eval expression: " + err.Error()) + return err + } + + if rs.Value == false { + n.logger.Info("condition not met, skip this branch") + return errors.New("condition not met") + } + + n.logger.Info("condition met, continue to next node") return nil } -func (n *conditionNode) eval(ctx context.Context, expression domain.Expr) (any, error) { - switch expr:=expression.(type) { - case domain.CompareExpr: - left,err:= n.eval(ctx, expr.Left) - if err != nil { - return nil, err - } - right,err:= n.eval(ctx, expr.Right) - if err != nil { - return nil, err - } - - case domain.LogicalExpr: - case domain.NotExpr: - case domain.VarExpr: - case domain.ConstExpr: - } - return false, errors.New("unknown expression type") +func (n *conditionNode) eval(ctx context.Context, expression domain.Expr) (*domain.EvalResult, error) { + variables := GetNodeOutputs(ctx) + return expression.Eval(variables) } diff --git a/ui/src/components/workflow/node/ConditionNode.tsx b/ui/src/components/workflow/node/ConditionNode.tsx index d3f1defc..7b2cb554 100644 --- a/ui/src/components/workflow/node/ConditionNode.tsx +++ b/ui/src/components/workflow/node/ConditionNode.tsx @@ -57,7 +57,7 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP break; } - const right: Expr = { type: "const", value: value }; + const right: Expr = { type: "const", value: value, valueType: t }; return { type: "compare", diff --git a/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx b/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx index f2d08253..e040dc78 100644 --- a/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx +++ b/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx @@ -318,6 +318,7 @@ const formToExpression = (values: ConditionNodeConfigFormFieldValues): Expr => { const right: Expr = { type: "const", value: rightValue, + valueType: type, }; return { diff --git a/ui/src/domain/workflow.ts b/ui/src/domain/workflow.ts index 9cd12287..05c936a7 100644 --- a/ui/src/domain/workflow.ts +++ b/ui/src/domain/workflow.ts @@ -238,7 +238,7 @@ export type ComparisonOperator = ">" | "<" | ">=" | "<=" | "==" | "!=" | "is"; export type LogicalOperator = "and" | "or" | "not"; -export type ConstExpr = { type: "const"; value: Value }; +export type ConstExpr = { type: "const"; value: Value; valueType: WorkflowNodeIoValueType }; export type VarExpr = { type: "var"; selector: WorkflowNodeIOValueSelector }; export type CompareExpr = { type: "compare"; op: ComparisonOperator; left: Expr; right: Expr }; export type LogicalExpr = { type: "logical"; op: LogicalOperator; left: Expr; right: Expr }; From 993ca36755ad8af5a4a6181d252beb9071882314 Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Wed, 21 May 2025 13:48:54 +0800 Subject: [PATCH 09/14] add certificate mornitoring node --- internal/domain/workflow.go | 23 +++ .../workflow/node-processor/inspect_node.go | 159 ++++++++++++++++++ .../node-processor/inspect_node_test.go | 39 +++++ internal/workflow/node-processor/processor.go | 2 + .../components/workflow/WorkflowElement.tsx | 4 + ui/src/components/workflow/node/AddNode.tsx | 2 + .../workflow/node/ConditionNode.tsx | 1 - .../workflow/node/ConditionNodeConfigForm.tsx | 1 - .../components/workflow/node/InspectNode.tsx | 90 ++++++++++ .../workflow/node/InspectNodeConfigForm.tsx | 85 ++++++++++ ui/src/domain/workflow.ts | 20 ++- .../i18n/locales/en/nls.workflow.nodes.json | 7 +- .../i18n/locales/zh/nls.workflow.nodes.json | 7 +- 13 files changed, 435 insertions(+), 5 deletions(-) create mode 100644 internal/workflow/node-processor/inspect_node.go create mode 100644 internal/workflow/node-processor/inspect_node_test.go create mode 100644 ui/src/components/workflow/node/InspectNode.tsx create mode 100644 ui/src/components/workflow/node/InspectNodeConfigForm.tsx diff --git a/internal/domain/workflow.go b/internal/domain/workflow.go index 63237221..c1d2db86 100644 --- a/internal/domain/workflow.go +++ b/internal/domain/workflow.go @@ -38,6 +38,7 @@ const ( WorkflowNodeTypeExecuteResultBranch = WorkflowNodeType("execute_result_branch") WorkflowNodeTypeExecuteSuccess = WorkflowNodeType("execute_success") WorkflowNodeTypeExecuteFailure = WorkflowNodeType("execute_failure") + WorkflowNodeTypeInspect = WorkflowNodeType("inspect") ) type WorkflowTriggerType string @@ -86,6 +87,11 @@ type WorkflowNodeConfigForCondition struct { Expression Expr `json:"expression"` // 条件表达式 } +type WorkflowNodeConfigForInspect struct { + Domain string `json:"domain"` // 域名 + Port string `json:"port"` // 端口 +} + type WorkflowNodeConfigForUpload struct { Certificate string `json:"certificate"` PrivateKey string `json:"privateKey"` @@ -127,6 +133,23 @@ func (n *WorkflowNode) GetConfigForCondition() WorkflowNodeConfigForCondition { } } +func (n *WorkflowNode) GetConfigForInspect() WorkflowNodeConfigForInspect { + domain := maputil.GetString(n.Config, "domain") + if domain == "" { + return WorkflowNodeConfigForInspect{} + } + + port := maputil.GetString(n.Config, "port") + if port == "" { + port = "443" + } + + return WorkflowNodeConfigForInspect{ + Domain: domain, + Port: port, + } +} + func (n *WorkflowNode) GetConfigForApply() WorkflowNodeConfigForApply { skipBeforeExpiryDays := maputil.GetInt32(n.Config, "skipBeforeExpiryDays") if skipBeforeExpiryDays == 0 { diff --git a/internal/workflow/node-processor/inspect_node.go b/internal/workflow/node-processor/inspect_node.go new file mode 100644 index 00000000..6c6bea6a --- /dev/null +++ b/internal/workflow/node-processor/inspect_node.go @@ -0,0 +1,159 @@ +package nodeprocessor + +import ( + "context" + "crypto/tls" + "fmt" + "math" + "net" + "time" + + "github.com/usual2970/certimate/internal/domain" +) + +type inspectNode struct { + node *domain.WorkflowNode + *nodeProcessor + *nodeOutputer +} + +func NewInspectNode(node *domain.WorkflowNode) *inspectNode { + return &inspectNode{ + node: node, + nodeProcessor: newNodeProcessor(node), + nodeOutputer: newNodeOutputer(), + } +} + +func (n *inspectNode) Process(ctx context.Context) error { + n.logger.Info("enter inspect website certificate node ...") + + nodeConfig := n.node.GetConfigForInspect() + + err := n.inspect(ctx, nodeConfig) + if err != nil { + n.logger.Warn("inspect website certificate failed: " + err.Error()) + return err + } + + return nil +} + +func (n *inspectNode) inspect(ctx context.Context, nodeConfig domain.WorkflowNodeConfigForInspect) error { + // 定义重试参数 + maxRetries := 3 + retryInterval := 2 * time.Second + + var cert *tls.Certificate + var lastError error + + domainWithPort := nodeConfig.Domain + ":" + nodeConfig.Port + + for attempt := 0; attempt < maxRetries; attempt++ { + if attempt > 0 { + n.logger.Info(fmt.Sprintf("Retry #%d connecting to %s", attempt, domainWithPort)) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(retryInterval): + // Wait for retry interval + } + } + + dialer := &net.Dialer{ + Timeout: 10 * time.Second, + } + + conn, err := tls.DialWithDialer(dialer, "tcp", domainWithPort, &tls.Config{ + InsecureSkipVerify: true, // Allow self-signed certificates + }) + if err != nil { + lastError = fmt.Errorf("failed to connect to %s: %w", domainWithPort, err) + n.logger.Warn(fmt.Sprintf("Connection attempt #%d failed: %s", attempt+1, lastError.Error())) + continue + } + + // Get certificate information + certInfo := conn.ConnectionState().PeerCertificates[0] + conn.Close() + + // Certificate information retrieved successfully + cert = &tls.Certificate{ + Certificate: [][]byte{certInfo.Raw}, + Leaf: certInfo, + } + lastError = nil + n.logger.Info(fmt.Sprintf("Successfully retrieved certificate information for %s", domainWithPort)) + break + } + + if lastError != nil { + return fmt.Errorf("failed to retrieve certificate after %d attempts: %w", maxRetries, lastError) + } + + certInfo := cert.Leaf + now := time.Now() + + isValid := now.Before(certInfo.NotAfter) && now.After(certInfo.NotBefore) + + // Check domain matching + domainMatch := false + if len(certInfo.DNSNames) > 0 { + for _, dnsName := range certInfo.DNSNames { + if matchDomain(nodeConfig.Domain, dnsName) { + domainMatch = true + break + } + } + } else if matchDomain(nodeConfig.Domain, certInfo.Subject.CommonName) { + domainMatch = true + } + + isValid = isValid && domainMatch + + daysRemaining := math.Floor(certInfo.NotAfter.Sub(now).Hours() / 24) + + // Set node outputs + outputs := map[string]any{ + "certificate.validated": isValid, + "certificate.daysLeft": daysRemaining, + } + n.setOutputs(outputs) + + return nil +} + +func (n *inspectNode) setOutputs(outputs map[string]any) { + n.outputs = outputs +} + +func matchDomain(requestDomain, certDomain string) bool { + if requestDomain == certDomain { + return true + } + + if len(certDomain) > 2 && certDomain[0] == '*' && certDomain[1] == '.' { + + wildcardSuffix := certDomain[1:] + requestDomainLen := len(requestDomain) + suffixLen := len(wildcardSuffix) + + if requestDomainLen > suffixLen && requestDomain[requestDomainLen-suffixLen:] == wildcardSuffix { + remainingPart := requestDomain[:requestDomainLen-suffixLen] + if len(remainingPart) > 0 && !contains(remainingPart, '.') { + return true + } + } + } + + return false +} + +func contains(s string, c byte) bool { + for i := 0; i < len(s); i++ { + if s[i] == c { + return true + } + } + return false +} diff --git a/internal/workflow/node-processor/inspect_node_test.go b/internal/workflow/node-processor/inspect_node_test.go new file mode 100644 index 00000000..5cb826c1 --- /dev/null +++ b/internal/workflow/node-processor/inspect_node_test.go @@ -0,0 +1,39 @@ +package nodeprocessor + +import ( + "context" + "testing" + + "github.com/usual2970/certimate/internal/domain" +) + +func Test_inspectWebsiteCertificateNode_inspect(t *testing.T) { + type args struct { + ctx context.Context + nodeConfig domain.WorkflowNodeConfigForInspect + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "test1", + args: args{ + ctx: context.Background(), + nodeConfig: domain.WorkflowNodeConfigForInspect{ + Domain: "baidu.com", + Port: "443", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + n := NewInspectNode(&domain.WorkflowNode{}) + if err := n.inspect(tt.args.ctx, tt.args.nodeConfig); (err != nil) != tt.wantErr { + t.Errorf("inspectWebsiteCertificateNode.inspect() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/workflow/node-processor/processor.go b/internal/workflow/node-processor/processor.go index eb7bc155..4cdfe76f 100644 --- a/internal/workflow/node-processor/processor.go +++ b/internal/workflow/node-processor/processor.go @@ -86,6 +86,8 @@ func GetProcessor(node *domain.WorkflowNode) (NodeProcessor, error) { return NewExecuteSuccessNode(node), nil case domain.WorkflowNodeTypeExecuteFailure: return NewExecuteFailureNode(node), nil + case domain.WorkflowNodeTypeInspect: + return NewInspectNode(node), nil } return nil, fmt.Errorf("supported node type: %s", string(node.Type)) diff --git a/ui/src/components/workflow/WorkflowElement.tsx b/ui/src/components/workflow/WorkflowElement.tsx index 3aa70ff3..d36029df 100644 --- a/ui/src/components/workflow/WorkflowElement.tsx +++ b/ui/src/components/workflow/WorkflowElement.tsx @@ -12,6 +12,7 @@ import ExecuteResultNode from "./node/ExecuteResultNode"; import NotifyNode from "./node/NotifyNode"; import StartNode from "./node/StartNode"; import UploadNode from "./node/UploadNode"; +import InspectNode from "./node/InspectNode"; export type WorkflowElementProps = { node: WorkflowNode; @@ -31,6 +32,9 @@ const WorkflowElement = ({ node, disabled, branchId, branchIndex }: WorkflowElem case WorkflowNodeType.Upload: return ; + + case WorkflowNodeType.Inspect: + return ; case WorkflowNodeType.Deploy: return ; diff --git a/ui/src/components/workflow/node/AddNode.tsx b/ui/src/components/workflow/node/AddNode.tsx index fb697e19..bf4c5be2 100644 --- a/ui/src/components/workflow/node/AddNode.tsx +++ b/ui/src/components/workflow/node/AddNode.tsx @@ -7,6 +7,7 @@ import { SendOutlined as SendOutlinedIcon, SisternodeOutlined as SisternodeOutlinedIcon, SolutionOutlined as SolutionOutlinedIcon, + MonitorOutlined as MonitorOutlinedIcon, } from "@ant-design/icons"; import { Dropdown } from "antd"; @@ -27,6 +28,7 @@ const AddNode = ({ node, disabled }: AddNodeProps) => { return [ [WorkflowNodeType.Apply, "workflow_node.apply.label", ], [WorkflowNodeType.Upload, "workflow_node.upload.label", ], + [WorkflowNodeType.Inspect, "workflow_node.inspect.label", ], [WorkflowNodeType.Deploy, "workflow_node.deploy.label", ], [WorkflowNodeType.Notify, "workflow_node.notify.label", ], [WorkflowNodeType.Branch, "workflow_node.branch.label", ], diff --git a/ui/src/components/workflow/node/ConditionNode.tsx b/ui/src/components/workflow/node/ConditionNode.tsx index 7b2cb554..417db4af 100644 --- a/ui/src/components/workflow/node/ConditionNode.tsx +++ b/ui/src/components/workflow/node/ConditionNode.tsx @@ -156,4 +156,3 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP }; export default memo(ConditionNode); - diff --git a/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx b/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx index e040dc78..81022a28 100644 --- a/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx +++ b/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx @@ -350,4 +350,3 @@ const formToExpression = (values: ConditionNodeConfigFormFieldValues): Expr => { }; export default memo(ConditionNodeConfigForm); - diff --git a/ui/src/components/workflow/node/InspectNode.tsx b/ui/src/components/workflow/node/InspectNode.tsx new file mode 100644 index 00000000..fa4324e2 --- /dev/null +++ b/ui/src/components/workflow/node/InspectNode.tsx @@ -0,0 +1,90 @@ +import { memo, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Flex, Typography } from "antd"; +import { produce } from "immer"; + +import { type WorkflowNodeConfigForInspect, WorkflowNodeType } from "@/domain/workflow"; +import { useZustandShallowSelector } from "@/hooks"; +import { useWorkflowStore } from "@/stores/workflow"; + +import SharedNode, { type SharedNodeProps } from "./_SharedNode"; +import InspectNodeConfigForm, { type InspectNodeConfigFormInstance } from "./InspectNodeConfigForm"; + +export type InspectNodeProps = SharedNodeProps; + +const InspectNode = ({ node, disabled }: InspectNodeProps) => { + if (node.type !== WorkflowNodeType.Inspect) { + console.warn(`[certimate] current workflow node type is not: ${WorkflowNodeType.Inspect}`); + } + + 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 WorkflowNodeConfigForInspect; + + const wrappedEl = useMemo(() => { + if (node.type !== WorkflowNodeType.Inspect) { + console.warn(`[certimate] current workflow node type is not: ${WorkflowNodeType.Inspect}`); + } + + if (!node.validated) { + return {t("workflow_node.action.configure_node")}; + } + + const config = (node.config as WorkflowNodeConfigForInspect) ?? {}; + return ( + + {config.domain ?? ""} + + ); + }, [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(InspectNode); diff --git a/ui/src/components/workflow/node/InspectNodeConfigForm.tsx b/ui/src/components/workflow/node/InspectNodeConfigForm.tsx new file mode 100644 index 00000000..ea9573e5 --- /dev/null +++ b/ui/src/components/workflow/node/InspectNodeConfigForm.tsx @@ -0,0 +1,85 @@ +import { forwardRef, memo, useImperativeHandle } from "react"; +import { useTranslation } from "react-i18next"; +import { Form, type FormInstance, Input } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import { z } from "zod"; + +import { type WorkflowNodeConfigForInspect } from "@/domain/workflow"; +import { useAntdForm } from "@/hooks"; + +import { validDomainName, validPortNumber } from "@/utils/validators"; + +type InspectNodeConfigFormFieldValues = Partial; + +export type InspectNodeConfigFormProps = { + className?: string; + style?: React.CSSProperties; + disabled?: boolean; + initialValues?: InspectNodeConfigFormFieldValues; + onValuesChange?: (values: InspectNodeConfigFormFieldValues) => void; +}; + +export type InspectNodeConfigFormInstance = { + getFieldsValue: () => ReturnType["getFieldsValue"]>; + resetFields: FormInstance["resetFields"]; + validateFields: FormInstance["validateFields"]; +}; + +const initFormModel = (): InspectNodeConfigFormFieldValues => { + return { + domain: "", + port: "443", + }; +}; + +const InspectNodeConfigForm = forwardRef( + ({ className, style, disabled, initialValues, onValuesChange }, ref) => { + const { t } = useTranslation(); + + const formSchema = z.object({ + domain: z.string().refine((val) => validDomainName(val), { + message: t("workflow_node.inspect.form.domain.placeholder"), + }), + port: z.string().refine((val) => validPortNumber(val), { + message: t("workflow_node.inspect.form.port.placeholder"), + }), + }); + const formRule = createSchemaFieldRule(formSchema); + const { form: formInst, formProps } = useAntdForm({ + name: "workflowNodeInspectConfigForm", + initialValues: initialValues ?? initFormModel(), + }); + + const handleFormChange = (_: unknown, values: z.infer) => { + onValuesChange?.(values as InspectNodeConfigFormFieldValues); + }; + + useImperativeHandle(ref, () => { + return { + getFieldsValue: () => { + return formInst.getFieldsValue(true); + }, + resetFields: (fields) => { + return formInst.resetFields(fields as (keyof InspectNodeConfigFormFieldValues)[]); + }, + validateFields: (nameList, config) => { + return formInst.validateFields(nameList, config); + }, + } as InspectNodeConfigFormInstance; + }); + + return ( +
+ + + + + + + +
+ ); + } +); + +export default memo(InspectNodeConfigForm); diff --git a/ui/src/domain/workflow.ts b/ui/src/domain/workflow.ts index 05c936a7..6b951b49 100644 --- a/ui/src/domain/workflow.ts +++ b/ui/src/domain/workflow.ts @@ -31,6 +31,7 @@ export enum WorkflowNodeType { End = "end", Apply = "apply", Upload = "upload", + Inspect = "inspect", Deploy = "deploy", Notify = "notify", Branch = "branch", @@ -46,6 +47,7 @@ const workflowNodeTypeDefaultNames: Map = new Map([ [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.Inspect, i18n.t("workflow_node.inspect.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")], @@ -95,6 +97,17 @@ const workflowNodeTypeDefaultOutputs: Map = }, ], ], + [ + WorkflowNodeType.Inspect, + [ + { + name: "certificate", + type: "certificate", + required: true, + label: "证书", + }, + ], + ], [WorkflowNodeType.Deploy, []], [WorkflowNodeType.Notify, []], ]); @@ -145,6 +158,11 @@ export type WorkflowNodeConfigForUpload = { privateKey: string; }; +export type WorkflowNodeConfigForInspect = { + domain: string; + port: string; +}; + export type WorkflowNodeConfigForDeploy = { certificate: string; provider: string; @@ -313,6 +331,7 @@ export const newNode = (nodeType: WorkflowNodeType, options: NewNodeOptions = {} case WorkflowNodeType.Apply: case WorkflowNodeType.Upload: case WorkflowNodeType.Deploy: + case WorkflowNodeType.Inspect: { node.inputs = workflowNodeTypeDefaultInputs.get(nodeType); node.outputs = workflowNodeTypeDefaultOutputs.get(nodeType); @@ -582,4 +601,3 @@ export const isAllNodesValidated = (node: WorkflowNode): boolean => { return true; }; - diff --git a/ui/src/i18n/locales/en/nls.workflow.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json index a0288838..80cba031 100644 --- a/ui/src/i18n/locales/en/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/en/nls.workflow.nodes.json @@ -753,6 +753,12 @@ "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.inspect.label": "Inspect certificate", + "workflow_node.inspect.form.domain.label": "Domain", + "workflow_node.inspect.form.domain.placeholder": "Please enter domain name", + "workflow_node.inspect.form.port.label": "Port", + "workflow_node.inspect.form.port.placeholder": "Please enter port", + "workflow_node.notify.label": "Notification", "workflow_node.notify.form.subject.label": "Subject", "workflow_node.notify.form.subject.placeholder": "Please enter subject", @@ -817,4 +823,3 @@ "workflow_node.execute_failure.label": "If the previous node failed ..." } - diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index 9381aa71..4fce90b7 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -752,6 +752,12 @@ "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.inspect.label": "检查网站证书", + "workflow_node.inspect.form.domain.label": "域名", + "workflow_node.inspect.form.domain.placeholder": "请输入要检查的网站域名", + "workflow_node.inspect.form.port.label": "端口号", + "workflow_node.inspect.form.port.placeholder": "请输入要检查的端口号", + "workflow_node.notify.label": "推送通知", "workflow_node.notify.form.subject.label": "通知主题", "workflow_node.notify.form.subject.placeholder": "请输入通知主题", @@ -816,4 +822,3 @@ "workflow_node.execute_failure.label": "若前序节点执行失败…" } - From 75326b1dddc82a6d45f3757b7a3b60a393f0c88f Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Wed, 21 May 2025 15:59:02 +0800 Subject: [PATCH 10/14] refactor code --- internal/domain/expr.go | 79 ++++++++++++++++++++----------------- internal/domain/workflow.go | 6 +-- 2 files changed, 45 insertions(+), 40 deletions(-) diff --git a/internal/domain/expr.go b/internal/domain/expr.go index 4791ba7d..9d1a744e 100644 --- a/internal/domain/expr.go +++ b/internal/domain/expr.go @@ -10,6 +10,7 @@ type Value any type ( ComparisonOperator string LogicalOperator string + ValueType string ) const ( @@ -24,15 +25,19 @@ const ( And LogicalOperator = "and" Or LogicalOperator = "or" Not LogicalOperator = "not" + + Number ValueType = "number" + String ValueType = "string" + Boolean ValueType = "boolean" ) type EvalResult struct { - Type string + Type ValueType Value any } func (e *EvalResult) GetFloat64() (float64, error) { - if e.Type != "number" { + if e.Type != Number { return 0, fmt.Errorf("type mismatch: %s", e.Type) } switch v := e.Value.(type) { @@ -50,7 +55,7 @@ func (e *EvalResult) GreaterThan(other *EvalResult) (*EvalResult, error) { return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) } switch e.Type { - case "number": + case Number: left, err := e.GetFloat64() if err != nil { @@ -62,12 +67,12 @@ func (e *EvalResult) GreaterThan(other *EvalResult) (*EvalResult, error) { } return &EvalResult{ - Type: "boolean", + Type: Boolean, Value: left > right, }, nil - case "string": + case String: return &EvalResult{ - Type: "boolean", + Type: Boolean, Value: e.Value.(string) > other.Value.(string), }, nil @@ -81,7 +86,7 @@ func (e *EvalResult) GreaterOrEqual(other *EvalResult) (*EvalResult, error) { return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) } switch e.Type { - case "number": + case Number: left, err := e.GetFloat64() if err != nil { return nil, err @@ -91,12 +96,12 @@ func (e *EvalResult) GreaterOrEqual(other *EvalResult) (*EvalResult, error) { return nil, err } return &EvalResult{ - Type: "boolean", + Type: Boolean, Value: left >= right, }, nil - case "string": + case String: return &EvalResult{ - Type: "boolean", + Type: Boolean, Value: e.Value.(string) >= other.Value.(string), }, nil @@ -110,7 +115,7 @@ func (e *EvalResult) LessThan(other *EvalResult) (*EvalResult, error) { return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) } switch e.Type { - case "number": + case Number: left, err := e.GetFloat64() if err != nil { return nil, err @@ -120,12 +125,12 @@ func (e *EvalResult) LessThan(other *EvalResult) (*EvalResult, error) { return nil, err } return &EvalResult{ - Type: "boolean", + Type: Boolean, Value: left < right, }, nil - case "string": + case String: return &EvalResult{ - Type: "boolean", + Type: Boolean, Value: e.Value.(string) < other.Value.(string), }, nil @@ -139,7 +144,7 @@ func (e *EvalResult) LessOrEqual(other *EvalResult) (*EvalResult, error) { return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) } switch e.Type { - case "number": + case Number: left, err := e.GetFloat64() if err != nil { return nil, err @@ -149,12 +154,12 @@ func (e *EvalResult) LessOrEqual(other *EvalResult) (*EvalResult, error) { return nil, err } return &EvalResult{ - Type: "boolean", + Type: Boolean, Value: left <= right, }, nil - case "string": + case String: return &EvalResult{ - Type: "boolean", + Type: Boolean, Value: e.Value.(string) <= other.Value.(string), }, nil @@ -168,7 +173,7 @@ func (e *EvalResult) Equal(other *EvalResult) (*EvalResult, error) { return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) } switch e.Type { - case "number": + case Number: left, err := e.GetFloat64() if err != nil { return nil, err @@ -178,12 +183,12 @@ func (e *EvalResult) Equal(other *EvalResult) (*EvalResult, error) { return nil, err } return &EvalResult{ - Type: "boolean", + Type: Boolean, Value: left == right, }, nil - case "string": + case String: return &EvalResult{ - Type: "boolean", + Type: Boolean, Value: e.Value.(string) == other.Value.(string), }, nil @@ -197,7 +202,7 @@ func (e *EvalResult) NotEqual(other *EvalResult) (*EvalResult, error) { return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) } switch e.Type { - case "number": + case Number: left, err := e.GetFloat64() if err != nil { return nil, err @@ -207,12 +212,12 @@ func (e *EvalResult) NotEqual(other *EvalResult) (*EvalResult, error) { return nil, err } return &EvalResult{ - Type: "boolean", + Type: Boolean, Value: left != right, }, nil - case "string": + case String: return &EvalResult{ - Type: "boolean", + Type: Boolean, Value: e.Value.(string) != other.Value.(string), }, nil @@ -226,9 +231,9 @@ func (e *EvalResult) And(other *EvalResult) (*EvalResult, error) { return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) } switch e.Type { - case "boolean": + case Boolean: return &EvalResult{ - Type: "boolean", + Type: Boolean, Value: e.Value.(bool) && other.Value.(bool), }, nil default: @@ -241,9 +246,9 @@ func (e *EvalResult) Or(other *EvalResult) (*EvalResult, error) { return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) } switch e.Type { - case "boolean": + case Boolean: return &EvalResult{ - Type: "boolean", + Type: Boolean, Value: e.Value.(bool) || other.Value.(bool), }, nil default: @@ -252,11 +257,11 @@ func (e *EvalResult) Or(other *EvalResult) (*EvalResult, error) { } func (e *EvalResult) Not() (*EvalResult, error) { - if e.Type != "boolean" { + if e.Type != Boolean { return nil, fmt.Errorf("type mismatch: %s", e.Type) } return &EvalResult{ - Type: "boolean", + Type: Boolean, Value: !e.Value.(bool), }, nil } @@ -266,9 +271,9 @@ func (e *EvalResult) Is(other *EvalResult) (*EvalResult, error) { return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) } switch e.Type { - case "boolean": + case Boolean: return &EvalResult{ - Type: "boolean", + Type: Boolean, Value: e.Value.(bool) == other.Value.(bool), }, nil default: @@ -282,9 +287,9 @@ type Expr interface { } type ConstExpr struct { - Type string `json:"type"` - Value Value `json:"value"` - ValueType string `json:"valueType"` + Type string `json:"type"` + Value Value `json:"value"` + ValueType ValueType `json:"valueType"` } func (c ConstExpr) GetType() string { return c.Type } diff --git a/internal/domain/workflow.go b/internal/domain/workflow.go index 5213eff4..8f6522a5 100644 --- a/internal/domain/workflow.go +++ b/internal/domain/workflow.go @@ -210,9 +210,9 @@ type WorkflowNodeIO struct { } type WorkflowNodeIOValueSelector struct { - Id string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` + Id string `json:"id"` + Name string `json:"name"` + Type ValueType `json:"type"` } const WorkflowNodeIONameCertificate string = "certificate" From 9cdc59b2726ce5701bd01e25e36f1d0d30ea8b82 Mon Sep 17 00:00:00 2001 From: "Yoan.liu" Date: Thu, 22 May 2025 17:09:14 +0800 Subject: [PATCH 11/14] refactor code --- internal/domain/expr.go | 126 +++++++++---- internal/domain/workflow.go | 13 +- .../workflow/node-processor/apply_node.go | 10 +- internal/workflow/node-processor/const.go | 6 + .../workflow/node-processor/inspect_node.go | 176 +++++++++++------- .../workflow/node-processor/upload_node.go | 8 +- .../workflow/node/ConditionNode.tsx | 27 +-- .../workflow/node/ConditionNodeConfigForm.tsx | 26 +-- .../components/workflow/node/InspectNode.tsx | 2 +- .../workflow/node/InspectNodeConfigForm.tsx | 22 ++- ui/src/domain/workflow.ts | 52 ++++-- ui/src/i18n/locales/en/nls.workflow.json | 6 +- .../i18n/locales/en/nls.workflow.nodes.json | 4 + ui/src/i18n/locales/zh/nls.workflow.json | 6 +- .../i18n/locales/zh/nls.workflow.nodes.json | 4 + 15 files changed, 312 insertions(+), 176 deletions(-) create mode 100644 internal/workflow/node-processor/const.go diff --git a/internal/domain/expr.go b/internal/domain/expr.go index 9d1a744e..01730e3d 100644 --- a/internal/domain/expr.go +++ b/internal/domain/expr.go @@ -3,6 +3,7 @@ package domain import ( "encoding/json" "fmt" + "strconv" ) type Value any @@ -11,6 +12,7 @@ type ( ComparisonOperator string LogicalOperator string ValueType string + ExprType string ) const ( @@ -29,6 +31,12 @@ const ( Number ValueType = "number" String ValueType = "string" Boolean ValueType = "boolean" + + ConstExprType ExprType = "const" + VarExprType ExprType = "var" + CompareExprType ExprType = "compare" + LogicalExprType ExprType = "logical" + NotExprType ExprType = "not" ) type EvalResult struct { @@ -40,14 +48,40 @@ func (e *EvalResult) GetFloat64() (float64, error) { if e.Type != Number { return 0, fmt.Errorf("type mismatch: %s", e.Type) } - switch v := e.Value.(type) { - case int: - return float64(v), nil - case float64: - return v, nil - default: - return 0, fmt.Errorf("unsupported type: %T", v) + + stringValue, ok := e.Value.(string) + if !ok { + return 0, fmt.Errorf("value is not a string: %v", e.Value) } + + floatValue, err := strconv.ParseFloat(stringValue, 64) + if err != nil { + return 0, fmt.Errorf("failed to parse float64: %v", err) + } + return floatValue, nil +} + +func (e *EvalResult) GetBool() (bool, error) { + if e.Type != Boolean { + return false, fmt.Errorf("type mismatch: %s", e.Type) + } + + strValue, ok := e.Value.(string) + if ok { + if strValue == "true" { + return true, nil + } else if strValue == "false" { + return false, nil + } + return false, fmt.Errorf("value is not a boolean: %v", e.Value) + } + + boolValue, ok := e.Value.(bool) + if !ok { + return false, fmt.Errorf("value is not a boolean: %v", e.Value) + } + + return boolValue, nil } func (e *EvalResult) GreaterThan(other *EvalResult) (*EvalResult, error) { @@ -232,9 +266,17 @@ func (e *EvalResult) And(other *EvalResult) (*EvalResult, error) { } switch e.Type { case Boolean: + left, err := e.GetBool() + if err != nil { + return nil, err + } + right, err := other.GetBool() + if err != nil { + return nil, err + } return &EvalResult{ Type: Boolean, - Value: e.Value.(bool) && other.Value.(bool), + Value: left && right, }, nil default: return nil, fmt.Errorf("unsupported type: %s", e.Type) @@ -247,9 +289,17 @@ func (e *EvalResult) Or(other *EvalResult) (*EvalResult, error) { } switch e.Type { case Boolean: + left, err := e.GetBool() + if err != nil { + return nil, err + } + right, err := other.GetBool() + if err != nil { + return nil, err + } return &EvalResult{ Type: Boolean, - Value: e.Value.(bool) || other.Value.(bool), + Value: left || right, }, nil default: return nil, fmt.Errorf("unsupported type: %s", e.Type) @@ -260,9 +310,13 @@ func (e *EvalResult) Not() (*EvalResult, error) { if e.Type != Boolean { return nil, fmt.Errorf("type mismatch: %s", e.Type) } + boolValue, err := e.GetBool() + if err != nil { + return nil, err + } return &EvalResult{ Type: Boolean, - Value: !e.Value.(bool), + Value: !boolValue, }, nil } @@ -272,9 +326,17 @@ func (e *EvalResult) Is(other *EvalResult) (*EvalResult, error) { } switch e.Type { case Boolean: + left, err := e.GetBool() + if err != nil { + return nil, err + } + right, err := other.GetBool() + if err != nil { + return nil, err + } return &EvalResult{ Type: Boolean, - Value: e.Value.(bool) == other.Value.(bool), + Value: left == right, }, nil default: return nil, fmt.Errorf("unsupported type: %s", e.Type) @@ -282,17 +344,17 @@ func (e *EvalResult) Is(other *EvalResult) (*EvalResult, error) { } type Expr interface { - GetType() string + GetType() ExprType Eval(variables map[string]map[string]any) (*EvalResult, error) } type ConstExpr struct { - Type string `json:"type"` + Type ExprType `json:"type"` Value Value `json:"value"` ValueType ValueType `json:"valueType"` } -func (c ConstExpr) GetType() string { return c.Type } +func (c ConstExpr) GetType() ExprType { return c.Type } func (c ConstExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) { return &EvalResult{ @@ -302,11 +364,11 @@ func (c ConstExpr) Eval(variables map[string]map[string]any) (*EvalResult, error } type VarExpr struct { - Type string `json:"type"` + Type ExprType `json:"type"` Selector WorkflowNodeIOValueSelector `json:"selector"` } -func (v VarExpr) GetType() string { return v.Type } +func (v VarExpr) GetType() ExprType { return v.Type } func (v VarExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) { if v.Selector.Id == "" { @@ -330,13 +392,13 @@ func (v VarExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) } type CompareExpr struct { - Type string `json:"type"` // compare + Type ExprType `json:"type"` // compare Op ComparisonOperator `json:"op"` Left Expr `json:"left"` Right Expr `json:"right"` } -func (c CompareExpr) GetType() string { return c.Type } +func (c CompareExpr) GetType() ExprType { return c.Type } func (c CompareExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) { left, err := c.Left.Eval(variables) @@ -369,13 +431,13 @@ func (c CompareExpr) Eval(variables map[string]map[string]any) (*EvalResult, err } type LogicalExpr struct { - Type string `json:"type"` // logical + Type ExprType `json:"type"` // logical Op LogicalOperator `json:"op"` Left Expr `json:"left"` Right Expr `json:"right"` } -func (l LogicalExpr) GetType() string { return l.Type } +func (l LogicalExpr) GetType() ExprType { return l.Type } func (l LogicalExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) { left, err := l.Left.Eval(variables) @@ -398,11 +460,11 @@ func (l LogicalExpr) Eval(variables map[string]map[string]any) (*EvalResult, err } type NotExpr struct { - Type string `json:"type"` // not - Expr Expr `json:"expr"` + Type ExprType `json:"type"` // not + Expr Expr `json:"expr"` } -func (n NotExpr) GetType() string { return n.Type } +func (n NotExpr) GetType() ExprType { return n.Type } func (n NotExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) { inner, err := n.Expr.Eval(variables) @@ -413,7 +475,7 @@ func (n NotExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) } type rawExpr struct { - Type string `json:"type"` + Type ExprType `json:"type"` } func MarshalExpr(e Expr) ([]byte, error) { @@ -427,31 +489,31 @@ func UnmarshalExpr(data []byte) (Expr, error) { } switch typ.Type { - case "const": + case ConstExprType: var e ConstExpr if err := json.Unmarshal(data, &e); err != nil { return nil, err } return e, nil - case "var": + case VarExprType: var e VarExpr if err := json.Unmarshal(data, &e); err != nil { return nil, err } return e, nil - case "compare": + case CompareExprType: var e CompareExprRaw if err := json.Unmarshal(data, &e); err != nil { return nil, err } return e.ToCompareExpr() - case "logical": + case LogicalExprType: var e LogicalExprRaw if err := json.Unmarshal(data, &e); err != nil { return nil, err } return e.ToLogicalExpr() - case "not": + case NotExprType: var e NotExprRaw if err := json.Unmarshal(data, &e); err != nil { return nil, err @@ -463,7 +525,7 @@ func UnmarshalExpr(data []byte) (Expr, error) { } type CompareExprRaw struct { - Type string `json:"type"` + Type ExprType `json:"type"` Op ComparisonOperator `json:"op"` Left json.RawMessage `json:"left"` Right json.RawMessage `json:"right"` @@ -487,7 +549,7 @@ func (r CompareExprRaw) ToCompareExpr() (CompareExpr, error) { } type LogicalExprRaw struct { - Type string `json:"type"` + Type ExprType `json:"type"` Op LogicalOperator `json:"op"` Left json.RawMessage `json:"left"` Right json.RawMessage `json:"right"` @@ -511,7 +573,7 @@ func (r LogicalExprRaw) ToLogicalExpr() (LogicalExpr, error) { } type NotExprRaw struct { - Type string `json:"type"` + Type ExprType `json:"type"` Expr json.RawMessage `json:"expr"` } diff --git a/internal/domain/workflow.go b/internal/domain/workflow.go index 8f6522a5..afa379a8 100644 --- a/internal/domain/workflow.go +++ b/internal/domain/workflow.go @@ -88,8 +88,10 @@ type WorkflowNodeConfigForCondition struct { } type WorkflowNodeConfigForInspect struct { + Host string `json:"host"` // 主机 Domain string `json:"domain"` // 域名 Port string `json:"port"` // 端口 + Path string `json:"path"` // 路径 } type WorkflowNodeConfigForUpload struct { @@ -134,9 +136,14 @@ func (n *WorkflowNode) GetConfigForCondition() WorkflowNodeConfigForCondition { } func (n *WorkflowNode) GetConfigForInspect() WorkflowNodeConfigForInspect { + host := maputil.GetString(n.Config, "host") + if host == "" { + return WorkflowNodeConfigForInspect{} + } + domain := maputil.GetString(n.Config, "domain") if domain == "" { - return WorkflowNodeConfigForInspect{} + domain = host } port := maputil.GetString(n.Config, "port") @@ -144,9 +151,13 @@ func (n *WorkflowNode) GetConfigForInspect() WorkflowNodeConfigForInspect { port = "443" } + path := maputil.GetString(n.Config, "path") + return WorkflowNodeConfigForInspect{ Domain: domain, Port: port, + Host: host, + Path: path, } } diff --git a/internal/workflow/node-processor/apply_node.go b/internal/workflow/node-processor/apply_node.go index 7ace68ef..e5cc7274 100644 --- a/internal/workflow/node-processor/apply_node.go +++ b/internal/workflow/node-processor/apply_node.go @@ -100,8 +100,8 @@ func (n *applyNode) Process(ctx context.Context) error { } // 添加中间结果 - n.outputs["certificate.validated"] = true - n.outputs["certificate.daysLeft"] = int(time.Until(certificate.ExpireAt).Hours() / 24) + n.outputs[outputCertificateValidatedKey] = "true" + n.outputs[outputCertificateDaysLeftKey] = fmt.Sprintf("%d", int(time.Until(certificate.ExpireAt).Hours()/24)) n.logger.Info("apply completed") @@ -146,9 +146,9 @@ func (n *applyNode) checkCanSkip(ctx context.Context, lastOutput *domain.Workflo renewalInterval := time.Duration(currentNodeConfig.SkipBeforeExpiryDays) * time.Hour * 24 expirationTime := time.Until(lastCertificate.ExpireAt) if expirationTime > renewalInterval { - - n.outputs["certificate.validated"] = true - n.outputs["certificate.daysLeft"] = int(expirationTime.Hours() / 24) + + n.outputs[outputCertificateValidatedKey] = "true" + n.outputs[outputCertificateDaysLeftKey] = fmt.Sprintf("%d", int(expirationTime.Hours()/24)) return true, fmt.Sprintf("the certificate has already been issued (expires in %dd, next renewal in %dd)", int(expirationTime.Hours()/24), currentNodeConfig.SkipBeforeExpiryDays) } diff --git a/internal/workflow/node-processor/const.go b/internal/workflow/node-processor/const.go new file mode 100644 index 00000000..c1af01c9 --- /dev/null +++ b/internal/workflow/node-processor/const.go @@ -0,0 +1,6 @@ +package nodeprocessor + +const ( + outputCertificateValidatedKey = "certificate.validated" + outputCertificateDaysLeftKey = "certificate.daysLeft" +) diff --git a/internal/workflow/node-processor/inspect_node.go b/internal/workflow/node-processor/inspect_node.go index 6c6bea6a..a8661f37 100644 --- a/internal/workflow/node-processor/inspect_node.go +++ b/internal/workflow/node-processor/inspect_node.go @@ -3,9 +3,12 @@ package nodeprocessor import ( "context" "crypto/tls" + "crypto/x509" "fmt" "math" "net" + "net/http" + "strings" "time" "github.com/usual2970/certimate/internal/domain" @@ -26,13 +29,13 @@ func NewInspectNode(node *domain.WorkflowNode) *inspectNode { } func (n *inspectNode) Process(ctx context.Context) error { - n.logger.Info("enter inspect website certificate node ...") + n.logger.Info("entering inspect certificate node...") nodeConfig := n.node.GetConfigForInspect() err := n.inspect(ctx, nodeConfig) if err != nil { - n.logger.Warn("inspect website certificate failed: " + err.Error()) + n.logger.Warn("inspect certificate failed: " + err.Error()) return err } @@ -40,18 +43,35 @@ func (n *inspectNode) Process(ctx context.Context) error { } func (n *inspectNode) inspect(ctx context.Context, nodeConfig domain.WorkflowNodeConfigForInspect) error { - // 定义重试参数 maxRetries := 3 retryInterval := 2 * time.Second - var cert *tls.Certificate var lastError error + var certInfo *x509.Certificate - domainWithPort := nodeConfig.Domain + ":" + nodeConfig.Port + host := nodeConfig.Host + + port := nodeConfig.Port + if port == "" { + port = "443" + } + + domain := nodeConfig.Domain + if domain == "" { + domain = host + } + + path := nodeConfig.Path + if path != "" && !strings.HasPrefix(path, "/") { + path = "/" + path + } + + targetAddr := fmt.Sprintf("%s:%s", host, port) + n.logger.Info(fmt.Sprintf("Inspecting certificate at %s (validating domain: %s)", targetAddr, domain)) for attempt := 0; attempt < maxRetries; attempt++ { if attempt > 0 { - n.logger.Info(fmt.Sprintf("Retry #%d connecting to %s", attempt, domainWithPort)) + n.logger.Info(fmt.Sprintf("Retry #%d connecting to %s", attempt, targetAddr)) select { case <-ctx.Done(): return ctx.Err() @@ -60,30 +80,65 @@ func (n *inspectNode) inspect(ctx context.Context, nodeConfig domain.WorkflowNod } } - dialer := &net.Dialer{ - Timeout: 10 * time.Second, + transport := &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + }).DialContext, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + ServerName: domain, // Set SNI to domain for proper certificate selection + }, + ForceAttemptHTTP2: false, + DisableKeepAlives: true, } - conn, err := tls.DialWithDialer(dialer, "tcp", domainWithPort, &tls.Config{ - InsecureSkipVerify: true, // Allow self-signed certificates - }) + client := &http.Client{ + Transport: transport, + Timeout: 15 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + scheme := "https" + urlStr := fmt.Sprintf("%s://%s", scheme, targetAddr) + if path != "" { + urlStr = urlStr + path + } + + req, err := http.NewRequestWithContext(ctx, "HEAD", urlStr, nil) if err != nil { - lastError = fmt.Errorf("failed to connect to %s: %w", domainWithPort, err) + lastError = fmt.Errorf("failed to create HTTP request: %w", err) + n.logger.Warn(fmt.Sprintf("Request creation attempt #%d failed: %s", attempt+1, lastError.Error())) + continue + } + + if domain != host { + req.Host = domain + } + + req.Header.Set("User-Agent", "CertificateValidator/1.0") + req.Header.Set("Accept", "*/*") + + resp, err := client.Do(req) + if err != nil { + lastError = fmt.Errorf("HTTP request failed: %w", err) n.logger.Warn(fmt.Sprintf("Connection attempt #%d failed: %s", attempt+1, lastError.Error())) continue } - // Get certificate information - certInfo := conn.ConnectionState().PeerCertificates[0] - conn.Close() - - // Certificate information retrieved successfully - cert = &tls.Certificate{ - Certificate: [][]byte{certInfo.Raw}, - Leaf: certInfo, + if resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 { + resp.Body.Close() + lastError = fmt.Errorf("no TLS certificates received in HTTP response") + n.logger.Warn(fmt.Sprintf("Certificate retrieval attempt #%d failed: %s", attempt+1, lastError.Error())) + continue } + + certInfo = resp.TLS.PeerCertificates[0] + resp.Body.Close() + lastError = nil - n.logger.Info(fmt.Sprintf("Successfully retrieved certificate information for %s", domainWithPort)) + n.logger.Info(fmt.Sprintf("Successfully retrieved certificate from %s", targetAddr)) break } @@ -91,69 +146,46 @@ func (n *inspectNode) inspect(ctx context.Context, nodeConfig domain.WorkflowNod return fmt.Errorf("failed to retrieve certificate after %d attempts: %w", maxRetries, lastError) } - certInfo := cert.Leaf - now := time.Now() - - isValid := now.Before(certInfo.NotAfter) && now.After(certInfo.NotBefore) - - // Check domain matching - domainMatch := false - if len(certInfo.DNSNames) > 0 { - for _, dnsName := range certInfo.DNSNames { - if matchDomain(nodeConfig.Domain, dnsName) { - domainMatch = true - break - } + if certInfo == nil { + outputs := map[string]any{ + outputCertificateValidatedKey: "false", + outputCertificateDaysLeftKey: "0", } - } else if matchDomain(nodeConfig.Domain, certInfo.Subject.CommonName) { - domainMatch = true + n.setOutputs(outputs) + return nil } - isValid = isValid && domainMatch + now := time.Now() + + isValidTime := now.Before(certInfo.NotAfter) && now.After(certInfo.NotBefore) + + domainMatch := true + if err := certInfo.VerifyHostname(domain); err != nil { + domainMatch = false + } + + isValid := isValidTime && domainMatch daysRemaining := math.Floor(certInfo.NotAfter.Sub(now).Hours() / 24) - // Set node outputs - outputs := map[string]any{ - "certificate.validated": isValid, - "certificate.daysLeft": daysRemaining, + isValidStr := "false" + if isValid { + isValidStr = "true" } + + outputs := map[string]any{ + outputCertificateValidatedKey: isValidStr, + outputCertificateDaysLeftKey: fmt.Sprintf("%d", int(daysRemaining)), + } + n.setOutputs(outputs) + n.logger.Info(fmt.Sprintf("Certificate inspection completed - Target: %s, Domain: %s, Valid: %s, Days Remaining: %d", + targetAddr, domain, isValidStr, int(daysRemaining))) + return nil } func (n *inspectNode) setOutputs(outputs map[string]any) { n.outputs = outputs } - -func matchDomain(requestDomain, certDomain string) bool { - if requestDomain == certDomain { - return true - } - - if len(certDomain) > 2 && certDomain[0] == '*' && certDomain[1] == '.' { - - wildcardSuffix := certDomain[1:] - requestDomainLen := len(requestDomain) - suffixLen := len(wildcardSuffix) - - if requestDomainLen > suffixLen && requestDomain[requestDomainLen-suffixLen:] == wildcardSuffix { - remainingPart := requestDomain[:requestDomainLen-suffixLen] - if len(remainingPart) > 0 && !contains(remainingPart, '.') { - return true - } - } - } - - return false -} - -func contains(s string, c byte) bool { - for i := 0; i < len(s); i++ { - if s[i] == c { - return true - } - } - return false -} diff --git a/internal/workflow/node-processor/upload_node.go b/internal/workflow/node-processor/upload_node.go index 7fbb1515..ab86807e 100644 --- a/internal/workflow/node-processor/upload_node.go +++ b/internal/workflow/node-processor/upload_node.go @@ -69,8 +69,8 @@ func (n *uploadNode) Process(ctx context.Context) error { return err } - n.outputs["certificate.validated"] = true - n.outputs["certificate.daysLeft"] = int(time.Until(certificate.ExpireAt).Hours() / 24) + n.outputs[outputCertificateValidatedKey] = "true" + n.outputs[outputCertificateDaysLeftKey] = fmt.Sprintf("%d", int(time.Until(certificate.ExpireAt).Hours()/24)) n.logger.Info("upload completed") @@ -91,8 +91,8 @@ func (n *uploadNode) checkCanSkip(ctx context.Context, lastOutput *domain.Workfl lastCertificate, _ := n.certRepo.GetByWorkflowNodeId(ctx, n.node.Id) if lastCertificate != nil { - n.outputs["certificate.validated"] = true - n.outputs["certificate.daysLeft"] = int(time.Until(lastCertificate.ExpireAt).Hours() / 24) + n.outputs[outputCertificateValidatedKey] = "true" + n.outputs[outputCertificateDaysLeftKey] = fmt.Sprintf("%d", int(time.Until(lastCertificate.ExpireAt).Hours()/24)) return true, "the certificate has already been uploaded" } } diff --git a/ui/src/components/workflow/node/ConditionNode.tsx b/ui/src/components/workflow/node/ConditionNode.tsx index 417db4af..bcd58c77 100644 --- a/ui/src/components/workflow/node/ConditionNode.tsx +++ b/ui/src/components/workflow/node/ConditionNode.tsx @@ -5,7 +5,7 @@ import { Button, Card, Popover } from "antd"; import SharedNode, { type SharedNodeProps } from "./_SharedNode"; import AddNode from "./AddNode"; import ConditionNodeConfigForm, { ConditionItem, ConditionNodeConfigFormFieldValues, ConditionNodeConfigFormInstance } from "./ConditionNodeConfigForm"; -import { Expr, WorkflowNodeIoValueType, Value } from "@/domain/workflow"; +import { Expr, WorkflowNodeIoValueType, ExprType } from "@/domain/workflow"; import { produce } from "immer"; import { useWorkflowStore } from "@/stores/workflow"; import { useZustandShallowSelector } from "@/hooks"; @@ -32,7 +32,7 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP const selectors = condition.leftSelector.split("#"); const t = selectors[2] as WorkflowNodeIoValueType; const left: Expr = { - type: "var", + type: ExprType.Var, selector: { id: selectors[0], name: selectors[1], @@ -40,27 +40,10 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP }, }; - let value: Value = condition.rightValue; - switch (t) { - case "boolean": - if (value === "true") { - value = true; - } else if (value === "false") { - value = false; - } - break; - case "number": - value = parseInt(value as string); - break; - case "string": - value = value as string; - break; - } - - const right: Expr = { type: "const", value: value, valueType: t }; + const right: Expr = { type: ExprType.Const, value: condition.rightValue, valueType: t }; return { - type: "compare", + type: ExprType.Compare, op: condition.operator, left, right, @@ -77,7 +60,7 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP for (let i = 1; i < values.conditions.length; i++) { expr = { - type: "logical", + type: ExprType.Logical, op: values.logicalOperator, left: expr, right: createComparisonExpr(values.conditions[i]), diff --git a/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx b/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx index 81022a28..9cbb56cc 100644 --- a/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx +++ b/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx @@ -14,6 +14,7 @@ import { WorkflowNode, workflowNodeIOOptions, WorkflowNodeIoValueType, + ExprType, } from "@/domain/workflow"; import { FormInstance } from "antd"; import { useZustandShallowSelector } from "@/hooks"; @@ -58,7 +59,7 @@ const initFormModel = (): ConditionNodeConfigFormFieldValues => { rightValue: "", }, ], - logicalOperator: "and", + logicalOperator: LogicalOperator.And, }; }; @@ -67,10 +68,10 @@ const expressionToForm = (expr?: Expr): ConditionNodeConfigFormFieldValues => { if (!expr) return initFormModel(); const conditions: ConditionItem[] = []; - let logicalOp: LogicalOperator = "and"; + let logicalOp: LogicalOperator = LogicalOperator.And; const extractComparisons = (expr: Expr): void => { - if (expr.type === "compare") { + if (expr.type === ExprType.Compare) { // 确保左侧是变量,右侧是常量 if (isVarExpr(expr.left) && isConstExpr(expr.right)) { conditions.push({ @@ -79,7 +80,7 @@ const expressionToForm = (expr?: Expr): ConditionNodeConfigFormFieldValues => { rightValue: String(expr.right.value), }); } - } else if (expr.type === "logical") { + } else if (expr.type === ExprType.Logical) { logicalOp = expr.op; extractComparisons(expr.left); extractComparisons(expr.right); @@ -304,25 +305,18 @@ const formToExpression = (values: ConditionNodeConfigFormFieldValues): Expr => { const type = typeStr as WorkflowNodeIoValueType; const left: Expr = { - type: "var", + type: ExprType.Var, selector: { id, name, type }, }; - let rightValue: any = condition.rightValue; - if (type === "number") { - rightValue = Number(condition.rightValue); - } else if (type === "boolean") { - rightValue = condition.rightValue === "true"; - } - const right: Expr = { - type: "const", - value: rightValue, + type: ExprType.Const, + value: condition.rightValue, valueType: type, }; return { - type: "compare", + type: ExprType.Compare, op: condition.operator, left, right, @@ -339,7 +333,7 @@ const formToExpression = (values: ConditionNodeConfigFormFieldValues): Expr => { for (let i = 1; i < values.conditions.length; i++) { expr = { - type: "logical", + type: ExprType.Logical, op: values.logicalOperator, left: expr, right: createComparisonExpr(values.conditions[i]), diff --git a/ui/src/components/workflow/node/InspectNode.tsx b/ui/src/components/workflow/node/InspectNode.tsx index fa4324e2..0d038894 100644 --- a/ui/src/components/workflow/node/InspectNode.tsx +++ b/ui/src/components/workflow/node/InspectNode.tsx @@ -39,7 +39,7 @@ const InspectNode = ({ node, disabled }: InspectNodeProps) => { const config = (node.config as WorkflowNodeConfigForInspect) ?? {}; return ( - {config.domain ?? ""} + {config.host ?? ""} ); }, [node]); diff --git a/ui/src/components/workflow/node/InspectNodeConfigForm.tsx b/ui/src/components/workflow/node/InspectNodeConfigForm.tsx index ea9573e5..2d7d83b0 100644 --- a/ui/src/components/workflow/node/InspectNodeConfigForm.tsx +++ b/ui/src/components/workflow/node/InspectNodeConfigForm.tsx @@ -7,7 +7,7 @@ import { z } from "zod"; import { type WorkflowNodeConfigForInspect } from "@/domain/workflow"; import { useAntdForm } from "@/hooks"; -import { validDomainName, validPortNumber } from "@/utils/validators"; +import { validDomainName, validIPv4Address, validPortNumber } from "@/utils/validators"; type InspectNodeConfigFormFieldValues = Partial; @@ -29,6 +29,8 @@ const initFormModel = (): InspectNodeConfigFormFieldValues => { return { domain: "", port: "443", + path: "", + host: "", }; }; @@ -37,12 +39,14 @@ const InspectNodeConfigForm = forwardRef validDomainName(val), { - message: t("workflow_node.inspect.form.domain.placeholder"), + host: z.string().refine((val) => validIPv4Address(val) || validDomainName(val), { + message: t("workflow_node.inspect.form.host.placeholder"), }), + domain: z.string().optional(), port: z.string().refine((val) => validPortNumber(val), { message: t("workflow_node.inspect.form.port.placeholder"), }), + path: z.string().optional(), }); const formRule = createSchemaFieldRule(formSchema); const { form: formInst, formProps } = useAntdForm({ @@ -70,13 +74,21 @@ const InspectNodeConfigForm = forwardRef - - + + + + + + + + + + ); } diff --git a/ui/src/domain/workflow.ts b/ui/src/domain/workflow.ts index 6b951b49..5a3e9821 100644 --- a/ui/src/domain/workflow.ts +++ b/ui/src/domain/workflow.ts @@ -67,7 +67,7 @@ const workflowNodeTypeDefaultInputs: Map = n name: "certificate", type: "certificate", required: true, - label: "证书", + label: i18n.t("workflow.variables.certificate.label"), }, ], ], @@ -82,7 +82,7 @@ const workflowNodeTypeDefaultOutputs: Map = name: "certificate", type: "certificate", required: true, - label: "证书", + label: i18n.t("workflow.variables.certificate.label"), }, ], ], @@ -93,7 +93,7 @@ const workflowNodeTypeDefaultOutputs: Map = name: "certificate", type: "certificate", required: true, - label: "证书", + label: i18n.t("workflow.variables.certificate.label"), }, ], ], @@ -104,7 +104,7 @@ const workflowNodeTypeDefaultOutputs: Map = name: "certificate", type: "certificate", required: true, - label: "证书", + label: i18n.t("workflow.variables.certificate.label"), }, ], ], @@ -161,6 +161,8 @@ export type WorkflowNodeConfigForUpload = { export type WorkflowNodeConfigForInspect = { domain: string; port: string; + host: string; + path: string; }; export type WorkflowNodeConfigForDeploy = { @@ -200,14 +202,20 @@ export type WorkflowNodeIO = { valueSelector?: WorkflowNodeIOValueSelector; }; +export const VALUE_TYPES = Object.freeze({ + STRING: "string", + NUMBER: "number", + BOOLEAN: "boolean", +} as const); + +export type WorkflowNodeIoValueType = (typeof VALUE_TYPES)[keyof typeof VALUE_TYPES]; + export type WorkflowNodeIOValueSelector = { id: string; name: string; type: WorkflowNodeIoValueType; }; -export type WorkflowNodeIoValueType = "string" | "number" | "boolean"; - type WorkflowNodeIOOptions = { label: string; value: string; @@ -224,12 +232,12 @@ export const workflowNodeIOOptions = (node: WorkflowNode) => { switch (output.type) { case "certificate": rs.options.push({ - label: `${node.name} - ${output.label} - 是否有效`, + label: `${node.name} - ${output.label} - ${i18n.t("workflow.variables.is_validated.label")}`, value: `${node.id}#${output.name}.validated#boolean`, }); rs.options.push({ - label: `${node.name} - ${output.label} - 剩余天数`, + label: `${node.name} - ${output.label} - ${i18n.t("workflow.variables.days_left.label")}`, value: `${node.id}#${output.name}.daysLeft#number`, }); break; @@ -254,22 +262,34 @@ export type Value = string | number | boolean; export type ComparisonOperator = ">" | "<" | ">=" | "<=" | "==" | "!=" | "is"; -export type LogicalOperator = "and" | "or" | "not"; +export enum LogicalOperator { + And = "and", + Or = "or", + Not = "not", +} -export type ConstExpr = { type: "const"; value: Value; valueType: WorkflowNodeIoValueType }; -export type VarExpr = { type: "var"; selector: WorkflowNodeIOValueSelector }; -export type CompareExpr = { type: "compare"; op: ComparisonOperator; left: Expr; right: Expr }; -export type LogicalExpr = { type: "logical"; op: LogicalOperator; left: Expr; right: Expr }; -export type NotExpr = { type: "not"; expr: Expr }; +export enum ExprType { + Const = "const", + Var = "var", + Compare = "compare", + Logical = "logical", + Not = "not", +} + +export type ConstExpr = { type: ExprType.Const; value: string; valueType: WorkflowNodeIoValueType }; +export type VarExpr = { type: ExprType.Var; selector: WorkflowNodeIOValueSelector }; +export type CompareExpr = { type: ExprType.Compare; op: ComparisonOperator; left: Expr; right: Expr }; +export type LogicalExpr = { type: ExprType.Logical; op: LogicalOperator; left: Expr; right: Expr }; +export type NotExpr = { type: ExprType.Not; expr: Expr }; export type Expr = ConstExpr | VarExpr | CompareExpr | LogicalExpr | NotExpr; export const isConstExpr = (expr: Expr): expr is ConstExpr => { - return expr.type === "const"; + return expr.type === ExprType.Const; }; export const isVarExpr = (expr: Expr): expr is VarExpr => { - return expr.type === "var"; + return expr.type === ExprType.Var; }; // #endregion diff --git a/ui/src/i18n/locales/en/nls.workflow.json b/ui/src/i18n/locales/en/nls.workflow.json index b4f9d7e6..cdf722a0 100644 --- a/ui/src/i18n/locales/en/nls.workflow.json +++ b/ui/src/i18n/locales/en/nls.workflow.json @@ -53,5 +53,9 @@ "workflow.detail.orchestration.action.run": "Run", "workflow.detail.orchestration.action.run.confirm": "You have unreleased changes. Do you really want to run this workflow based on the latest released version?", "workflow.detail.orchestration.action.run.prompt": "Running... Please check the history later", - "workflow.detail.runs.tab": "History runs" + "workflow.detail.runs.tab": "History runs", + + "workflow.variables.is_validated.label": "Is valid", + "workflow.variables.days_left.label": "Days left", + "workflow.variables.certificate.label": "Certificate" } diff --git a/ui/src/i18n/locales/en/nls.workflow.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json index 2555c36e..b70e38de 100644 --- a/ui/src/i18n/locales/en/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/en/nls.workflow.nodes.json @@ -806,6 +806,10 @@ "workflow_node.inspect.form.domain.placeholder": "Please enter domain name", "workflow_node.inspect.form.port.label": "Port", "workflow_node.inspect.form.port.placeholder": "Please enter port", + "workflow_node.inspect.form.host.label": "Host", + "workflow_node.inspect.form.host.placeholder": "Please enter host", + "workflow_node.inspect.form.path.label": "Path", + "workflow_node.inspect.form.path.placeholder": "Please enter path", "workflow_node.notify.label": "Notification", "workflow_node.notify.form.subject.label": "Subject", diff --git a/ui/src/i18n/locales/zh/nls.workflow.json b/ui/src/i18n/locales/zh/nls.workflow.json index 46cdc228..e86e796a 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.json +++ b/ui/src/i18n/locales/zh/nls.workflow.json @@ -53,5 +53,9 @@ "workflow.detail.orchestration.action.run": "执行", "workflow.detail.orchestration.action.run.confirm": "你有尚未发布的更改。确定要以最近一次发布的版本继续执行吗?", "workflow.detail.orchestration.action.run.prompt": "执行中……请稍后查看执行历史", - "workflow.detail.runs.tab": "执行历史" + "workflow.detail.runs.tab": "执行历史", + + "workflow.variables.is_validated.label": "是否有效", + "workflow.variables.days_left.label": "剩余天数", + "workflow.variables.certificate.label": "证书" } diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index 206daeeb..722568fe 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -805,6 +805,10 @@ "workflow_node.inspect.form.domain.placeholder": "请输入要检查的网站域名", "workflow_node.inspect.form.port.label": "端口号", "workflow_node.inspect.form.port.placeholder": "请输入要检查的端口号", + "workflow_node.inspect.form.host.label": "Host", + "workflow_node.inspect.form.host.placeholder": "请输入 Host", + "workflow_node.inspect.form.path.label": "Path", + "workflow_node.inspect.form.path.placeholder": "请输入 Path", "workflow_node.notify.label": "推送通知", "workflow_node.notify.form.subject.label": "通知主题", From 3a829ad53ba37d0fd7e4284cdaef55ebdf628b8b Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Wed, 28 May 2025 21:05:56 +0800 Subject: [PATCH 12/14] refactor: workflow monitor(aka inspect) node --- internal/domain/workflow.go | 98 ++++----- .../workflow/node-processor/apply_node.go | 8 +- .../workflow/node-processor/deploy_node.go | 7 +- .../workflow/node-processor/inspect_node.go | 191 ------------------ .../node-processor/inspect_node_test.go | 39 ---- .../workflow/node-processor/monitor_node.go | 164 +++++++++++++++ .../node-processor/monitor_node_test.go | 28 +++ .../workflow/node-processor/notify_node.go | 9 +- internal/workflow/node-processor/processor.go | 10 +- .../workflow/node-processor/upload_node.go | 6 +- .../components/access/AccessFormSSHConfig.tsx | 2 +- ui/src/components/access/AccessSelect.tsx | 4 +- .../provider/ACMEDns01ProviderPicker.tsx | 2 +- .../provider/ACMEDns01ProviderSelect.tsx | 2 +- .../provider/AccessProviderPicker.tsx | 4 +- .../provider/AccessProviderSelect.tsx | 4 +- .../components/provider/CAProviderSelect.tsx | 2 +- .../provider/DeploymentProviderPicker.tsx | 2 +- .../provider/DeploymentProviderSelect.tsx | 2 +- .../provider/NotificationProviderSelect.tsx | 2 +- .../components/workflow/WorkflowElement.tsx | 11 +- ui/src/components/workflow/node/AddNode.tsx | 4 +- .../workflow/node/ConditionNode.tsx | 13 +- .../components/workflow/node/DeployNode.tsx | 2 +- .../workflow/node/InspectNodeConfigForm.tsx | 97 --------- .../node/{InspectNode.tsx => MonitorNode.tsx} | 28 +-- .../workflow/node/MonitorNodeConfigForm.tsx | 115 +++++++++++ .../components/workflow/node/NotifyNode.tsx | 2 +- .../components/workflow/node/UnknownNode.tsx | 45 +++++ ui/src/domain/provider.ts | 16 +- ui/src/domain/workflow.ts | 42 ++-- .../i18n/locales/en/nls.workflow.nodes.json | 55 +++-- ui/src/i18n/locales/zh/nls.access.json | 2 +- .../i18n/locales/zh/nls.workflow.nodes.json | 53 +++-- ui/src/pages/accesses/AccessList.tsx | 2 +- 35 files changed, 557 insertions(+), 516 deletions(-) delete mode 100644 internal/workflow/node-processor/inspect_node.go delete mode 100644 internal/workflow/node-processor/inspect_node_test.go create mode 100644 internal/workflow/node-processor/monitor_node.go create mode 100644 internal/workflow/node-processor/monitor_node_test.go delete mode 100644 ui/src/components/workflow/node/InspectNodeConfigForm.tsx rename ui/src/components/workflow/node/{InspectNode.tsx => MonitorNode.tsx} (72%) create mode 100644 ui/src/components/workflow/node/MonitorNodeConfigForm.tsx create mode 100644 ui/src/components/workflow/node/UnknownNode.tsx diff --git a/internal/domain/workflow.go b/internal/domain/workflow.go index afa379a8..7d7355c5 100644 --- a/internal/domain/workflow.go +++ b/internal/domain/workflow.go @@ -31,6 +31,7 @@ const ( WorkflowNodeTypeEnd = WorkflowNodeType("end") WorkflowNodeTypeApply = WorkflowNodeType("apply") WorkflowNodeTypeUpload = WorkflowNodeType("upload") + WorkflowNodeTypeMonitor = WorkflowNodeType("monitor") WorkflowNodeTypeDeploy = WorkflowNodeType("deploy") WorkflowNodeTypeNotify = WorkflowNodeType("notify") WorkflowNodeTypeBranch = WorkflowNodeType("branch") @@ -38,7 +39,6 @@ const ( WorkflowNodeTypeExecuteResultBranch = WorkflowNodeType("execute_result_branch") WorkflowNodeTypeExecuteSuccess = WorkflowNodeType("execute_success") WorkflowNodeTypeExecuteFailure = WorkflowNodeType("execute_failure") - WorkflowNodeTypeInspect = WorkflowNodeType("inspect") ) type WorkflowTriggerType string @@ -83,21 +83,17 @@ type WorkflowNodeConfigForApply struct { SkipBeforeExpiryDays int32 `json:"skipBeforeExpiryDays,omitempty"` // 证书到期前多少天前跳过续期(零值将使用默认值 30) } -type WorkflowNodeConfigForCondition struct { - Expression Expr `json:"expression"` // 条件表达式 -} - -type WorkflowNodeConfigForInspect struct { - Host string `json:"host"` // 主机 - Domain string `json:"domain"` // 域名 - Port string `json:"port"` // 端口 - Path string `json:"path"` // 路径 -} - type WorkflowNodeConfigForUpload struct { - Certificate string `json:"certificate"` - PrivateKey string `json:"privateKey"` - Domains string `json:"domains"` + Certificate string `json:"certificate"` // 证书 PEM 内容 + PrivateKey string `json:"privateKey"` // 私钥 PEM 内容 + Domains string `json:"domains,omitempty"` +} + +type WorkflowNodeConfigForMonitor struct { + Host string `json:"host"` // 主机地址 + Port int32 `json:"port,omitempty"` // 端口(零值时默认值 443) + Domain string `json:"domain,omitempty"` // 域名(零值时默认值 [Host]) + RequestPath string `json:"requestPath,omitempty"` // 请求路径 } type WorkflowNodeConfigForDeploy struct { @@ -117,48 +113,8 @@ type WorkflowNodeConfigForNotify struct { Message string `json:"message"` // 通知内容 } -func (n *WorkflowNode) GetConfigForCondition() WorkflowNodeConfigForCondition { - expression := n.Config["expression"] - if expression == nil { - return WorkflowNodeConfigForCondition{} - } - - raw, _ := json.Marshal(expression) - - expr, err := UnmarshalExpr([]byte(raw)) - if err != nil { - return WorkflowNodeConfigForCondition{} - } - - return WorkflowNodeConfigForCondition{ - Expression: expr, - } -} - -func (n *WorkflowNode) GetConfigForInspect() WorkflowNodeConfigForInspect { - host := maputil.GetString(n.Config, "host") - if host == "" { - return WorkflowNodeConfigForInspect{} - } - - domain := maputil.GetString(n.Config, "domain") - if domain == "" { - domain = host - } - - port := maputil.GetString(n.Config, "port") - if port == "" { - port = "443" - } - - path := maputil.GetString(n.Config, "path") - - return WorkflowNodeConfigForInspect{ - Domain: domain, - Port: port, - Host: host, - Path: path, - } +type WorkflowNodeConfigForCondition struct { + Expression Expr `json:"expression"` // 条件表达式 } func (n *WorkflowNode) GetConfigForApply() WorkflowNodeConfigForApply { @@ -190,6 +146,16 @@ func (n *WorkflowNode) GetConfigForUpload() WorkflowNodeConfigForUpload { } } +func (n *WorkflowNode) GetConfigForMonitor() WorkflowNodeConfigForMonitor { + host := maputil.GetString(n.Config, "host") + return WorkflowNodeConfigForMonitor{ + Host: host, + Port: maputil.GetOrDefaultInt32(n.Config, "port", 443), + Domain: maputil.GetOrDefaultString(n.Config, "domain", host), + RequestPath: maputil.GetString(n.Config, "path"), + } +} + func (n *WorkflowNode) GetConfigForDeploy() WorkflowNodeConfigForDeploy { return WorkflowNodeConfigForDeploy{ Certificate: maputil.GetString(n.Config, "certificate"), @@ -211,6 +177,24 @@ func (n *WorkflowNode) GetConfigForNotify() WorkflowNodeConfigForNotify { } } +func (n *WorkflowNode) GetConfigForCondition() WorkflowNodeConfigForCondition { + expression := n.Config["expression"] + if expression == nil { + return WorkflowNodeConfigForCondition{} + } + + raw, _ := json.Marshal(expression) + + expr, err := UnmarshalExpr([]byte(raw)) + if err != nil { + return WorkflowNodeConfigForCondition{} + } + + return WorkflowNodeConfigForCondition{ + Expression: expr, + } +} + type WorkflowNodeIO struct { Label string `json:"label"` Name string `json:"name"` diff --git a/internal/workflow/node-processor/apply_node.go b/internal/workflow/node-processor/apply_node.go index e663e3fc..321d9fc8 100644 --- a/internal/workflow/node-processor/apply_node.go +++ b/internal/workflow/node-processor/apply_node.go @@ -34,7 +34,7 @@ func NewApplyNode(node *domain.WorkflowNode) *applyNode { } func (n *applyNode) Process(ctx context.Context) error { - n.logger.Info("ready to apply ...") + n.logger.Info("ready to obtain certificiate ...") // 查询上次执行结果 lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id) @@ -63,7 +63,7 @@ func (n *applyNode) Process(ctx context.Context) error { // 申请证书 applyResult, err := applicant.Apply(ctx) if err != nil { - n.logger.Warn("failed to apply") + n.logger.Warn("failed to obtain certificiate") return err } @@ -112,7 +112,7 @@ func (n *applyNode) Process(ctx context.Context) error { n.outputs[outputCertificateValidatedKey] = "true" n.outputs[outputCertificateDaysLeftKey] = fmt.Sprintf("%d", int(time.Until(certificate.ExpireAt).Hours()/24)) - n.logger.Info("apply completed") + n.logger.Info("application completed") return nil } @@ -156,7 +156,7 @@ func (n *applyNode) checkCanSkip(ctx context.Context, lastOutput *domain.Workflo if expirationTime > renewalInterval { n.outputs[outputCertificateValidatedKey] = "true" n.outputs[outputCertificateDaysLeftKey] = fmt.Sprintf("%d", int(expirationTime.Hours()/24)) - return true, fmt.Sprintf("the certificate has already been issued (expires in %dd, next renewal in %dd)", int(expirationTime.Hours()/24), currentNodeConfig.SkipBeforeExpiryDays) + return true, fmt.Sprintf("the certificate has already been issued (expires in %d day(s), next renewal in %d day(s))", int(expirationTime.Hours()/24), currentNodeConfig.SkipBeforeExpiryDays) } } } diff --git a/internal/workflow/node-processor/deploy_node.go b/internal/workflow/node-processor/deploy_node.go index 3819b4a2..f0ded21d 100644 --- a/internal/workflow/node-processor/deploy_node.go +++ b/internal/workflow/node-processor/deploy_node.go @@ -33,7 +33,7 @@ func NewDeployNode(node *domain.WorkflowNode) *deployNode { } func (n *deployNode) Process(ctx context.Context) error { - n.logger.Info("ready to deploy ...") + n.logger.Info("ready to deploy certificate ...") // 查询上次执行结果 lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id) @@ -78,7 +78,7 @@ func (n *deployNode) Process(ctx context.Context) error { // 部署证书 if err := deployer.Deploy(ctx); err != nil { - n.logger.Warn("failed to deploy") + n.logger.Warn("failed to deploy certificate") return err } @@ -95,8 +95,7 @@ func (n *deployNode) Process(ctx context.Context) error { return err } - n.logger.Info("deploy completed") - + n.logger.Info("deployment completed") return nil } diff --git a/internal/workflow/node-processor/inspect_node.go b/internal/workflow/node-processor/inspect_node.go deleted file mode 100644 index a8661f37..00000000 --- a/internal/workflow/node-processor/inspect_node.go +++ /dev/null @@ -1,191 +0,0 @@ -package nodeprocessor - -import ( - "context" - "crypto/tls" - "crypto/x509" - "fmt" - "math" - "net" - "net/http" - "strings" - "time" - - "github.com/usual2970/certimate/internal/domain" -) - -type inspectNode struct { - node *domain.WorkflowNode - *nodeProcessor - *nodeOutputer -} - -func NewInspectNode(node *domain.WorkflowNode) *inspectNode { - return &inspectNode{ - node: node, - nodeProcessor: newNodeProcessor(node), - nodeOutputer: newNodeOutputer(), - } -} - -func (n *inspectNode) Process(ctx context.Context) error { - n.logger.Info("entering inspect certificate node...") - - nodeConfig := n.node.GetConfigForInspect() - - err := n.inspect(ctx, nodeConfig) - if err != nil { - n.logger.Warn("inspect certificate failed: " + err.Error()) - return err - } - - return nil -} - -func (n *inspectNode) inspect(ctx context.Context, nodeConfig domain.WorkflowNodeConfigForInspect) error { - maxRetries := 3 - retryInterval := 2 * time.Second - - var lastError error - var certInfo *x509.Certificate - - host := nodeConfig.Host - - port := nodeConfig.Port - if port == "" { - port = "443" - } - - domain := nodeConfig.Domain - if domain == "" { - domain = host - } - - path := nodeConfig.Path - if path != "" && !strings.HasPrefix(path, "/") { - path = "/" + path - } - - targetAddr := fmt.Sprintf("%s:%s", host, port) - n.logger.Info(fmt.Sprintf("Inspecting certificate at %s (validating domain: %s)", targetAddr, domain)) - - for attempt := 0; attempt < maxRetries; attempt++ { - if attempt > 0 { - n.logger.Info(fmt.Sprintf("Retry #%d connecting to %s", attempt, targetAddr)) - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(retryInterval): - // Wait for retry interval - } - } - - transport := &http.Transport{ - DialContext: (&net.Dialer{ - Timeout: 10 * time.Second, - }).DialContext, - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - ServerName: domain, // Set SNI to domain for proper certificate selection - }, - ForceAttemptHTTP2: false, - DisableKeepAlives: true, - } - - client := &http.Client{ - Transport: transport, - Timeout: 15 * time.Second, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } - - scheme := "https" - urlStr := fmt.Sprintf("%s://%s", scheme, targetAddr) - if path != "" { - urlStr = urlStr + path - } - - req, err := http.NewRequestWithContext(ctx, "HEAD", urlStr, nil) - if err != nil { - lastError = fmt.Errorf("failed to create HTTP request: %w", err) - n.logger.Warn(fmt.Sprintf("Request creation attempt #%d failed: %s", attempt+1, lastError.Error())) - continue - } - - if domain != host { - req.Host = domain - } - - req.Header.Set("User-Agent", "CertificateValidator/1.0") - req.Header.Set("Accept", "*/*") - - resp, err := client.Do(req) - if err != nil { - lastError = fmt.Errorf("HTTP request failed: %w", err) - n.logger.Warn(fmt.Sprintf("Connection attempt #%d failed: %s", attempt+1, lastError.Error())) - continue - } - - if resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 { - resp.Body.Close() - lastError = fmt.Errorf("no TLS certificates received in HTTP response") - n.logger.Warn(fmt.Sprintf("Certificate retrieval attempt #%d failed: %s", attempt+1, lastError.Error())) - continue - } - - certInfo = resp.TLS.PeerCertificates[0] - resp.Body.Close() - - lastError = nil - n.logger.Info(fmt.Sprintf("Successfully retrieved certificate from %s", targetAddr)) - break - } - - if lastError != nil { - return fmt.Errorf("failed to retrieve certificate after %d attempts: %w", maxRetries, lastError) - } - - if certInfo == nil { - outputs := map[string]any{ - outputCertificateValidatedKey: "false", - outputCertificateDaysLeftKey: "0", - } - n.setOutputs(outputs) - return nil - } - - now := time.Now() - - isValidTime := now.Before(certInfo.NotAfter) && now.After(certInfo.NotBefore) - - domainMatch := true - if err := certInfo.VerifyHostname(domain); err != nil { - domainMatch = false - } - - isValid := isValidTime && domainMatch - - daysRemaining := math.Floor(certInfo.NotAfter.Sub(now).Hours() / 24) - - isValidStr := "false" - if isValid { - isValidStr = "true" - } - - outputs := map[string]any{ - outputCertificateValidatedKey: isValidStr, - outputCertificateDaysLeftKey: fmt.Sprintf("%d", int(daysRemaining)), - } - - n.setOutputs(outputs) - - n.logger.Info(fmt.Sprintf("Certificate inspection completed - Target: %s, Domain: %s, Valid: %s, Days Remaining: %d", - targetAddr, domain, isValidStr, int(daysRemaining))) - - return nil -} - -func (n *inspectNode) setOutputs(outputs map[string]any) { - n.outputs = outputs -} diff --git a/internal/workflow/node-processor/inspect_node_test.go b/internal/workflow/node-processor/inspect_node_test.go deleted file mode 100644 index 5cb826c1..00000000 --- a/internal/workflow/node-processor/inspect_node_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package nodeprocessor - -import ( - "context" - "testing" - - "github.com/usual2970/certimate/internal/domain" -) - -func Test_inspectWebsiteCertificateNode_inspect(t *testing.T) { - type args struct { - ctx context.Context - nodeConfig domain.WorkflowNodeConfigForInspect - } - tests := []struct { - name string - args args - wantErr bool - }{ - { - name: "test1", - args: args{ - ctx: context.Background(), - nodeConfig: domain.WorkflowNodeConfigForInspect{ - Domain: "baidu.com", - Port: "443", - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - n := NewInspectNode(&domain.WorkflowNode{}) - if err := n.inspect(tt.args.ctx, tt.args.nodeConfig); (err != nil) != tt.wantErr { - t.Errorf("inspectWebsiteCertificateNode.inspect() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} diff --git a/internal/workflow/node-processor/monitor_node.go b/internal/workflow/node-processor/monitor_node.go new file mode 100644 index 00000000..f8c1adae --- /dev/null +++ b/internal/workflow/node-processor/monitor_node.go @@ -0,0 +1,164 @@ +package nodeprocessor + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "math" + "net" + "net/http" + "strconv" + "strings" + "time" + + "github.com/usual2970/certimate/internal/domain" +) + +type monitorNode struct { + node *domain.WorkflowNode + *nodeProcessor + *nodeOutputer +} + +func NewMonitorNode(node *domain.WorkflowNode) *monitorNode { + return &monitorNode{ + node: node, + nodeProcessor: newNodeProcessor(node), + nodeOutputer: newNodeOutputer(), + } +} + +func (n *monitorNode) Process(ctx context.Context) error { + n.logger.Info("ready to monitor certificate ...") + + nodeConfig := n.node.GetConfigForMonitor() + + targetAddr := fmt.Sprintf("%s:%d", nodeConfig.Host, nodeConfig.Port) + if nodeConfig.Port == 0 { + targetAddr = fmt.Sprintf("%s:443", nodeConfig.Host) + } + + targetDomain := nodeConfig.Domain + if targetDomain == "" { + targetDomain = nodeConfig.Host + } + + n.logger.Info(fmt.Sprintf("retrieving certificate at %s (domain: %s)", targetAddr, targetDomain)) + + const MAX_ATTEMPTS = 3 + const RETRY_INTERVAL = 2 * time.Second + var cert *x509.Certificate + var err error + for attempt := 0; attempt < MAX_ATTEMPTS; attempt++ { + if attempt > 0 { + n.logger.Info(fmt.Sprintf("retry %d time(s) ...", attempt, targetAddr)) + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(RETRY_INTERVAL): + } + } + + cert, err = n.tryRetrieveCert(ctx, targetAddr, targetDomain, nodeConfig.RequestPath) + if err == nil { + break + } + } + + if err != nil { + n.logger.Warn("failed to monitor certificate") + return err + } else { + if cert == nil { + n.logger.Warn("no ssl certificates retrieved in http response") + + outputs := map[string]any{ + outputCertificateValidatedKey: strconv.FormatBool(false), + outputCertificateDaysLeftKey: strconv.FormatInt(0, 10), + } + n.setOutputs(outputs) + } else { + n.logger.Info(fmt.Sprintf("ssl certificate retrieved (serial='%s', subject='%s', issuer='%s', not_before='%s', not_after='%s', sans='%s')", + cert.SerialNumber, cert.Subject.String(), cert.Issuer.String(), + cert.NotBefore.Format(time.RFC3339), cert.NotAfter.Format(time.RFC3339), + strings.Join(cert.DNSNames, ";")), + ) + + now := time.Now() + isCertPeriodValid := now.Before(cert.NotAfter) && now.After(cert.NotBefore) + isCertHostMatched := true + if err := cert.VerifyHostname(targetDomain); err != nil { + isCertHostMatched = false + } + + validated := isCertPeriodValid && isCertHostMatched + daysLeft := int(math.Floor(cert.NotAfter.Sub(now).Hours() / 24)) + outputs := map[string]any{ + outputCertificateValidatedKey: strconv.FormatBool(validated), + outputCertificateDaysLeftKey: strconv.FormatInt(int64(daysLeft), 10), + } + n.setOutputs(outputs) + + if validated { + n.logger.Info(fmt.Sprintf("the certificate is valid, and will expire in %d day(s)", daysLeft)) + } else { + n.logger.Warn(fmt.Sprintf("the certificate is invalid", validated)) + } + } + } + + n.logger.Info("monitoring completed") + return nil +} + +func (n *monitorNode) tryRetrieveCert(ctx context.Context, addr, domain, requestPath string) (_cert *x509.Certificate, _err error) { + transport := &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + }).DialContext, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + ForceAttemptHTTP2: false, + DisableKeepAlives: true, + Proxy: http.ProxyFromEnvironment, + } + + client := &http.Client{ + Transport: transport, + Timeout: 15 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + url := fmt.Sprintf("https://%s/%s", addr, strings.TrimLeft(requestPath, "/")) + req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) + if err != nil { + _err = fmt.Errorf("failed to create http request: %w", err) + n.logger.Warn(fmt.Sprintf("failed to create http request: %w", err)) + return nil, _err + } + + req.Header.Set("User-Agent", "certimate") + resp, err := client.Do(req) + if err != nil { + _err = fmt.Errorf("failed to send http request: %w", err) + n.logger.Warn(fmt.Sprintf("failed to send http request: %w", err)) + return nil, _err + } + defer resp.Body.Close() + + if resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 { + return nil, _err + } + + _cert = resp.TLS.PeerCertificates[0] + return _cert, nil +} + +func (n *monitorNode) setOutputs(outputs map[string]any) { + n.outputs = outputs +} diff --git a/internal/workflow/node-processor/monitor_node_test.go b/internal/workflow/node-processor/monitor_node_test.go new file mode 100644 index 00000000..1cc0c876 --- /dev/null +++ b/internal/workflow/node-processor/monitor_node_test.go @@ -0,0 +1,28 @@ +package nodeprocessor_test + +import ( + "context" + "log/slog" + "testing" + + "github.com/usual2970/certimate/internal/domain" + nodeprocessor "github.com/usual2970/certimate/internal/workflow/node-processor" +) + +func Test_MonitorNode(t *testing.T) { + t.Run("Monitor", func(t *testing.T) { + node := nodeprocessor.NewMonitorNode(&domain.WorkflowNode{ + Id: "test", + Type: domain.WorkflowNodeTypeMonitor, + Name: "test", + Config: map[string]any{ + "host": "baidu.com", + "port": 443, + }, + }) + node.SetLogger(slog.Default()) + if err := node.Process(context.Background()); err != nil { + t.Errorf("err: %+v", err) + } + }) +} diff --git a/internal/workflow/node-processor/notify_node.go b/internal/workflow/node-processor/notify_node.go index 8f336931..f084cb4f 100644 --- a/internal/workflow/node-processor/notify_node.go +++ b/internal/workflow/node-processor/notify_node.go @@ -28,7 +28,7 @@ func NewNotifyNode(node *domain.WorkflowNode) *notifyNode { } func (n *notifyNode) Process(ctx context.Context) error { - n.logger.Info("ready to notify ...") + n.logger.Info("ready to send notification ...") nodeConfig := n.node.GetConfigForNotify() @@ -51,11 +51,11 @@ func (n *notifyNode) Process(ctx context.Context) error { // 发送通知 if err := notify.SendToChannel(nodeConfig.Subject, nodeConfig.Message, nodeConfig.Channel, channelConfig); err != nil { - n.logger.Warn("failed to notify", slog.String("channel", nodeConfig.Channel)) + n.logger.Warn("failed to send notification", slog.String("channel", nodeConfig.Channel)) return err } - n.logger.Info("notify completed") + n.logger.Info("notification completed") return nil } @@ -73,9 +73,10 @@ func (n *notifyNode) Process(ctx context.Context) error { // 推送通知 if err := deployer.Notify(ctx); err != nil { - n.logger.Warn("failed to notify") + n.logger.Warn("failed to send notification") return err } + n.logger.Info("notification completed") return nil } diff --git a/internal/workflow/node-processor/processor.go b/internal/workflow/node-processor/processor.go index 24de76d1..d375883f 100644 --- a/internal/workflow/node-processor/processor.go +++ b/internal/workflow/node-processor/processor.go @@ -74,25 +74,25 @@ func GetProcessor(node *domain.WorkflowNode) (NodeProcessor, error) { switch node.Type { case domain.WorkflowNodeTypeStart: return NewStartNode(node), nil - case domain.WorkflowNodeTypeCondition: - return NewConditionNode(node), nil case domain.WorkflowNodeTypeApply: return NewApplyNode(node), nil case domain.WorkflowNodeTypeUpload: return NewUploadNode(node), nil + case domain.WorkflowNodeTypeMonitor: + return NewMonitorNode(node), nil case domain.WorkflowNodeTypeDeploy: return NewDeployNode(node), nil case domain.WorkflowNodeTypeNotify: return NewNotifyNode(node), nil + case domain.WorkflowNodeTypeCondition: + return NewConditionNode(node), nil case domain.WorkflowNodeTypeExecuteSuccess: return NewExecuteSuccessNode(node), nil case domain.WorkflowNodeTypeExecuteFailure: return NewExecuteFailureNode(node), nil - case domain.WorkflowNodeTypeInspect: - return NewInspectNode(node), nil } - return nil, fmt.Errorf("supported node type: %s", string(node.Type)) + return nil, fmt.Errorf("unsupported node type: %s", string(node.Type)) } func getContextWorkflowId(ctx context.Context) string { diff --git a/internal/workflow/node-processor/upload_node.go b/internal/workflow/node-processor/upload_node.go index 6a59ca74..8e59b009 100644 --- a/internal/workflow/node-processor/upload_node.go +++ b/internal/workflow/node-processor/upload_node.go @@ -31,7 +31,7 @@ func NewUploadNode(node *domain.WorkflowNode) *uploadNode { } func (n *uploadNode) Process(ctx context.Context) error { - n.logger.Info("ready to upload ...") + n.logger.Info("ready to upload certiticate ...") nodeConfig := n.node.GetConfigForUpload() @@ -43,7 +43,7 @@ func (n *uploadNode) Process(ctx context.Context) error { // 检测是否可以跳过本次执行 if skippable, reason := n.checkCanSkip(ctx, lastOutput); skippable { - n.logger.Info(fmt.Sprintf("skip this upload, because %s", reason)) + n.logger.Info(fmt.Sprintf("skip this uploading, because %s", reason)) return nil } else if reason != "" { n.logger.Info(fmt.Sprintf("re-upload, because %s", reason)) @@ -72,7 +72,7 @@ func (n *uploadNode) Process(ctx context.Context) error { n.outputs[outputCertificateValidatedKey] = "true" n.outputs[outputCertificateDaysLeftKey] = fmt.Sprintf("%d", int(time.Until(certificate.ExpireAt).Hours()/24)) - n.logger.Info("upload completed") + n.logger.Info("uploading completed") return nil } diff --git a/ui/src/components/access/AccessFormSSHConfig.tsx b/ui/src/components/access/AccessFormSSHConfig.tsx index 32d1b8bc..c964b1b3 100644 --- a/ui/src/components/access/AccessFormSSHConfig.tsx +++ b/ui/src/components/access/AccessFormSSHConfig.tsx @@ -120,7 +120,7 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues
- +
diff --git a/ui/src/components/access/AccessSelect.tsx b/ui/src/components/access/AccessSelect.tsx index 7c112bbe..0a570699 100644 --- a/ui/src/components/access/AccessSelect.tsx +++ b/ui/src/components/access/AccessSelect.tsx @@ -37,7 +37,7 @@ const AccessSelect = ({ filter, ...props }: AccessTypeSelectProps) => { if (!access) { return ( - + {key} @@ -48,7 +48,7 @@ const AccessSelect = ({ filter, ...props }: AccessTypeSelectProps) => { const provider = accessProvidersMap.get(access.provider); return ( - + {access.name} diff --git a/ui/src/components/provider/ACMEDns01ProviderPicker.tsx b/ui/src/components/provider/ACMEDns01ProviderPicker.tsx index 0f20b296..5a5be8ca 100644 --- a/ui/src/components/provider/ACMEDns01ProviderPicker.tsx +++ b/ui/src/components/provider/ACMEDns01ProviderPicker.tsx @@ -67,7 +67,7 @@ const ACMEDns01ProviderPicker = ({ className, style, autoFocus, filter, placehol }} > - + {t(provider.name)}
diff --git a/ui/src/components/provider/ACMEDns01ProviderSelect.tsx b/ui/src/components/provider/ACMEDns01ProviderSelect.tsx index b03adf7b..e2408eeb 100644 --- a/ui/src/components/provider/ACMEDns01ProviderSelect.tsx +++ b/ui/src/components/provider/ACMEDns01ProviderSelect.tsx @@ -32,7 +32,7 @@ const ACMEDns01ProviderSelect = ({ filter, ...props }: ACMEDns01ProviderSelectPr const provider = acmeDns01ProvidersMap.get(key); return ( - + {t(provider?.name ?? "")} diff --git a/ui/src/components/provider/AccessProviderPicker.tsx b/ui/src/components/provider/AccessProviderPicker.tsx index 002d2519..507a95c8 100644 --- a/ui/src/components/provider/AccessProviderPicker.tsx +++ b/ui/src/components/provider/AccessProviderPicker.tsx @@ -86,12 +86,12 @@ const AccessProviderPicker = ({ className, style, autoFocus, filter, placeholder }} > - +
{t(provider.name)} -
+
{t("access.props.provider.builtin")} diff --git a/ui/src/components/provider/AccessProviderSelect.tsx b/ui/src/components/provider/AccessProviderSelect.tsx index 37f1626d..bf4ff6e7 100644 --- a/ui/src/components/provider/AccessProviderSelect.tsx +++ b/ui/src/components/provider/AccessProviderSelect.tsx @@ -49,12 +49,12 @@ const AccessProviderSelect = ({ filter, showOptionTags, ...props }: AccessProvid return (
- + {t(provider.name)} -
+
{t("access.props.provider.builtin")} diff --git a/ui/src/components/provider/CAProviderSelect.tsx b/ui/src/components/provider/CAProviderSelect.tsx index 15d31230..e5477c21 100644 --- a/ui/src/components/provider/CAProviderSelect.tsx +++ b/ui/src/components/provider/CAProviderSelect.tsx @@ -48,7 +48,7 @@ const CAProviderSelect = ({ filter, ...props }: CAProviderSelectProps) => { const provider = caProvidersMap.get(key); return ( - + {t(provider?.name ?? "")} diff --git a/ui/src/components/provider/DeploymentProviderPicker.tsx b/ui/src/components/provider/DeploymentProviderPicker.tsx index b1bcd6fe..bb569acd 100644 --- a/ui/src/components/provider/DeploymentProviderPicker.tsx +++ b/ui/src/components/provider/DeploymentProviderPicker.tsx @@ -104,7 +104,7 @@ const DeploymentProviderPicker = ({ className, style, autoFocus, filter, placeho > - + {t(provider.name)} diff --git a/ui/src/components/provider/DeploymentProviderSelect.tsx b/ui/src/components/provider/DeploymentProviderSelect.tsx index 0b38cedf..89173243 100644 --- a/ui/src/components/provider/DeploymentProviderSelect.tsx +++ b/ui/src/components/provider/DeploymentProviderSelect.tsx @@ -32,7 +32,7 @@ const DeploymentProviderSelect = ({ filter, ...props }: DeploymentProviderSelect const provider = deploymentProvidersMap.get(key); return ( - + {t(provider?.name ?? "")} diff --git a/ui/src/components/provider/NotificationProviderSelect.tsx b/ui/src/components/provider/NotificationProviderSelect.tsx index 98a1005c..f30a8f6f 100644 --- a/ui/src/components/provider/NotificationProviderSelect.tsx +++ b/ui/src/components/provider/NotificationProviderSelect.tsx @@ -32,7 +32,7 @@ const NotificationProviderSelect = ({ filter, ...props }: NotificationProviderSe const provider = notificationProvidersMap.get(key); return ( - + {t(provider?.name ?? "")} diff --git a/ui/src/components/workflow/WorkflowElement.tsx b/ui/src/components/workflow/WorkflowElement.tsx index d36029df..86720f6d 100644 --- a/ui/src/components/workflow/WorkflowElement.tsx +++ b/ui/src/components/workflow/WorkflowElement.tsx @@ -9,10 +9,11 @@ import DeployNode from "./node/DeployNode"; import EndNode from "./node/EndNode"; import ExecuteResultBranchNode from "./node/ExecuteResultBranchNode"; import ExecuteResultNode from "./node/ExecuteResultNode"; +import MonitorNode from "./node/MonitorNode"; import NotifyNode from "./node/NotifyNode"; import StartNode from "./node/StartNode"; +import UnknownNode from "./node/UnknownNode"; import UploadNode from "./node/UploadNode"; -import InspectNode from "./node/InspectNode"; export type WorkflowElementProps = { node: WorkflowNode; @@ -32,9 +33,9 @@ const WorkflowElement = ({ node, disabled, branchId, branchIndex }: WorkflowElem case WorkflowNodeType.Upload: return ; - - case WorkflowNodeType.Inspect: - return ; + + case WorkflowNodeType.Monitor: + return ; case WorkflowNodeType.Deploy: return ; @@ -60,7 +61,7 @@ const WorkflowElement = ({ node, disabled, branchId, branchIndex }: WorkflowElem default: console.warn(`[certimate] unsupported workflow node type: ${node.type}`); - return <>; + return ; } }, [node, disabled, branchId, branchIndex]); diff --git a/ui/src/components/workflow/node/AddNode.tsx b/ui/src/components/workflow/node/AddNode.tsx index bf4c5be2..86a45134 100644 --- a/ui/src/components/workflow/node/AddNode.tsx +++ b/ui/src/components/workflow/node/AddNode.tsx @@ -3,11 +3,11 @@ import { useTranslation } from "react-i18next"; import { CloudUploadOutlined as CloudUploadOutlinedIcon, DeploymentUnitOutlined as DeploymentUnitOutlinedIcon, + MonitorOutlined as MonitorOutlinedIcon, PlusOutlined as PlusOutlinedIcon, SendOutlined as SendOutlinedIcon, SisternodeOutlined as SisternodeOutlinedIcon, SolutionOutlined as SolutionOutlinedIcon, - MonitorOutlined as MonitorOutlinedIcon, } from "@ant-design/icons"; import { Dropdown } from "antd"; @@ -28,7 +28,7 @@ const AddNode = ({ node, disabled }: AddNodeProps) => { return [ [WorkflowNodeType.Apply, "workflow_node.apply.label", ], [WorkflowNodeType.Upload, "workflow_node.upload.label", ], - [WorkflowNodeType.Inspect, "workflow_node.inspect.label", ], + [WorkflowNodeType.Monitor, "workflow_node.monitor.label", ], [WorkflowNodeType.Deploy, "workflow_node.deploy.label", ], [WorkflowNodeType.Notify, "workflow_node.notify.label", ], [WorkflowNodeType.Branch, "workflow_node.branch.label", ], diff --git a/ui/src/components/workflow/node/ConditionNode.tsx b/ui/src/components/workflow/node/ConditionNode.tsx index bcd58c77..bc5b5918 100644 --- a/ui/src/components/workflow/node/ConditionNode.tsx +++ b/ui/src/components/workflow/node/ConditionNode.tsx @@ -1,14 +1,17 @@ import { memo, useRef, useState } from "react"; import { MoreOutlined as MoreOutlinedIcon } from "@ant-design/icons"; import { Button, Card, Popover } from "antd"; +import { produce } from "immer"; + +import type { Expr, WorkflowNodeIoValueType } from "@/domain/workflow"; +import { ExprType } from "@/domain/workflow"; +import { useZustandShallowSelector } from "@/hooks"; +import { useWorkflowStore } from "@/stores/workflow"; import SharedNode, { type SharedNodeProps } from "./_SharedNode"; import AddNode from "./AddNode"; -import ConditionNodeConfigForm, { ConditionItem, ConditionNodeConfigFormFieldValues, ConditionNodeConfigFormInstance } from "./ConditionNodeConfigForm"; -import { Expr, WorkflowNodeIoValueType, ExprType } from "@/domain/workflow"; -import { produce } from "immer"; -import { useWorkflowStore } from "@/stores/workflow"; -import { useZustandShallowSelector } from "@/hooks"; +import type { ConditionItem, ConditionNodeConfigFormFieldValues, ConditionNodeConfigFormInstance } from "./ConditionNodeConfigForm"; +import ConditionNodeConfigForm from "./ConditionNodeConfigForm"; export type ConditionNodeProps = SharedNodeProps & { branchId: string; diff --git a/ui/src/components/workflow/node/DeployNode.tsx b/ui/src/components/workflow/node/DeployNode.tsx index 92eb2890..b04516fb 100644 --- a/ui/src/components/workflow/node/DeployNode.tsx +++ b/ui/src/components/workflow/node/DeployNode.tsx @@ -46,7 +46,7 @@ const DeployNode = ({ node, disabled }: DeployNodeProps) => { const provider = deploymentProvidersMap.get(config.provider); return ( - + {t(provider?.name ?? "")} ); diff --git a/ui/src/components/workflow/node/InspectNodeConfigForm.tsx b/ui/src/components/workflow/node/InspectNodeConfigForm.tsx deleted file mode 100644 index 2d7d83b0..00000000 --- a/ui/src/components/workflow/node/InspectNodeConfigForm.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { forwardRef, memo, useImperativeHandle } from "react"; -import { useTranslation } from "react-i18next"; -import { Form, type FormInstance, Input } from "antd"; -import { createSchemaFieldRule } from "antd-zod"; -import { z } from "zod"; - -import { type WorkflowNodeConfigForInspect } from "@/domain/workflow"; -import { useAntdForm } from "@/hooks"; - -import { validDomainName, validIPv4Address, validPortNumber } from "@/utils/validators"; - -type InspectNodeConfigFormFieldValues = Partial; - -export type InspectNodeConfigFormProps = { - className?: string; - style?: React.CSSProperties; - disabled?: boolean; - initialValues?: InspectNodeConfigFormFieldValues; - onValuesChange?: (values: InspectNodeConfigFormFieldValues) => void; -}; - -export type InspectNodeConfigFormInstance = { - getFieldsValue: () => ReturnType["getFieldsValue"]>; - resetFields: FormInstance["resetFields"]; - validateFields: FormInstance["validateFields"]; -}; - -const initFormModel = (): InspectNodeConfigFormFieldValues => { - return { - domain: "", - port: "443", - path: "", - host: "", - }; -}; - -const InspectNodeConfigForm = forwardRef( - ({ className, style, disabled, initialValues, onValuesChange }, ref) => { - const { t } = useTranslation(); - - const formSchema = z.object({ - host: z.string().refine((val) => validIPv4Address(val) || validDomainName(val), { - message: t("workflow_node.inspect.form.host.placeholder"), - }), - domain: z.string().optional(), - port: z.string().refine((val) => validPortNumber(val), { - message: t("workflow_node.inspect.form.port.placeholder"), - }), - path: z.string().optional(), - }); - const formRule = createSchemaFieldRule(formSchema); - const { form: formInst, formProps } = useAntdForm({ - name: "workflowNodeInspectConfigForm", - initialValues: initialValues ?? initFormModel(), - }); - - const handleFormChange = (_: unknown, values: z.infer) => { - onValuesChange?.(values as InspectNodeConfigFormFieldValues); - }; - - useImperativeHandle(ref, () => { - return { - getFieldsValue: () => { - return formInst.getFieldsValue(true); - }, - resetFields: (fields) => { - return formInst.resetFields(fields as (keyof InspectNodeConfigFormFieldValues)[]); - }, - validateFields: (nameList, config) => { - return formInst.validateFields(nameList, config); - }, - } as InspectNodeConfigFormInstance; - }); - - return ( -
- - - - - - - - - - - - - - - -
- ); - } -); - -export default memo(InspectNodeConfigForm); diff --git a/ui/src/components/workflow/node/InspectNode.tsx b/ui/src/components/workflow/node/MonitorNode.tsx similarity index 72% rename from ui/src/components/workflow/node/InspectNode.tsx rename to ui/src/components/workflow/node/MonitorNode.tsx index 0d038894..68feb842 100644 --- a/ui/src/components/workflow/node/InspectNode.tsx +++ b/ui/src/components/workflow/node/MonitorNode.tsx @@ -3,43 +3,43 @@ import { useTranslation } from "react-i18next"; import { Flex, Typography } from "antd"; import { produce } from "immer"; -import { type WorkflowNodeConfigForInspect, WorkflowNodeType } from "@/domain/workflow"; +import { type WorkflowNodeConfigForMonitor, WorkflowNodeType } from "@/domain/workflow"; import { useZustandShallowSelector } from "@/hooks"; import { useWorkflowStore } from "@/stores/workflow"; import SharedNode, { type SharedNodeProps } from "./_SharedNode"; -import InspectNodeConfigForm, { type InspectNodeConfigFormInstance } from "./InspectNodeConfigForm"; +import MonitorNodeConfigForm, { type MonitorNodeConfigFormInstance } from "./MonitorNodeConfigForm"; -export type InspectNodeProps = SharedNodeProps; +export type MonitorNodeProps = SharedNodeProps; -const InspectNode = ({ node, disabled }: InspectNodeProps) => { - if (node.type !== WorkflowNodeType.Inspect) { - console.warn(`[certimate] current workflow node type is not: ${WorkflowNodeType.Inspect}`); +const MonitorNode = ({ node, disabled }: MonitorNodeProps) => { + if (node.type !== WorkflowNodeType.Monitor) { + console.warn(`[certimate] current workflow node type is not: ${WorkflowNodeType.Monitor}`); } const { t } = useTranslation(); const { updateNode } = useWorkflowStore(useZustandShallowSelector(["updateNode"])); - const formRef = useRef(null); + const formRef = useRef(null); const [formPending, setFormPending] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); - const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForInspect; + const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForMonitor; const wrappedEl = useMemo(() => { - if (node.type !== WorkflowNodeType.Inspect) { - console.warn(`[certimate] current workflow node type is not: ${WorkflowNodeType.Inspect}`); + if (node.type !== WorkflowNodeType.Monitor) { + console.warn(`[certimate] current workflow node type is not: ${WorkflowNodeType.Monitor}`); } if (!node.validated) { return {t("workflow_node.action.configure_node")}; } - const config = (node.config as WorkflowNodeConfigForInspect) ?? {}; + const config = (node.config as WorkflowNodeConfigForMonitor) ?? {}; return ( - {config.host ?? ""} + {config.domain || config.host || ""} ); }, [node]); @@ -81,10 +81,10 @@ const InspectNode = ({ node, disabled }: InspectNodeProps) => { onOpenChange={(open) => setDrawerOpen(open)} getFormValues={() => formRef.current!.getFieldsValue()} > - + ); }; -export default memo(InspectNode); +export default memo(MonitorNode); diff --git a/ui/src/components/workflow/node/MonitorNodeConfigForm.tsx b/ui/src/components/workflow/node/MonitorNodeConfigForm.tsx new file mode 100644 index 00000000..883124f9 --- /dev/null +++ b/ui/src/components/workflow/node/MonitorNodeConfigForm.tsx @@ -0,0 +1,115 @@ +import { forwardRef, memo, useImperativeHandle } from "react"; +import { useTranslation } from "react-i18next"; +import { Alert, Form, type FormInstance, Input, InputNumber } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import { z } from "zod"; + +import { type WorkflowNodeConfigForMonitor } from "@/domain/workflow"; +import { useAntdForm } from "@/hooks"; +import { validDomainName, validIPv4Address, validIPv6Address, validPortNumber } from "@/utils/validators"; + +type MonitorNodeConfigFormFieldValues = Partial; + +export type MonitorNodeConfigFormProps = { + className?: string; + style?: React.CSSProperties; + disabled?: boolean; + initialValues?: MonitorNodeConfigFormFieldValues; + onValuesChange?: (values: MonitorNodeConfigFormFieldValues) => void; +}; + +export type MonitorNodeConfigFormInstance = { + getFieldsValue: () => ReturnType["getFieldsValue"]>; + resetFields: FormInstance["resetFields"]; + validateFields: FormInstance["validateFields"]; +}; + +const initFormModel = (): MonitorNodeConfigFormFieldValues => { + return { + host: "", + port: 443, + requestPath: "/", + }; +}; + +const MonitorNodeConfigForm = forwardRef( + ({ className, style, disabled, initialValues, onValuesChange }, ref) => { + const { t } = useTranslation(); + + const formSchema = z.object({ + host: z.string().refine((v) => { + return validDomainName(v) || validIPv4Address(v) || validIPv6Address(v); + }, t("common.errmsg.host_invalid")), + port: z.preprocess( + (v) => Number(v), + z + .number() + .int(t("workflow_node.monitor.form.port.placeholder")) + .refine((v) => validPortNumber(v), t("common.errmsg.port_invalid")) + ), + domain: z + .string() + .nullish() + .refine((v) => { + if (!v) return true; + return validDomainName(v); + }, t("common.errmsg.domain_invalid")), + requestPath: z.string().nullish(), + }); + const formRule = createSchemaFieldRule(formSchema); + const { form: formInst, formProps } = useAntdForm({ + name: "workflowNodeMonitorConfigForm", + initialValues: initialValues ?? initFormModel(), + }); + + const handleFormChange = (_: unknown, values: z.infer) => { + onValuesChange?.(values as MonitorNodeConfigFormFieldValues); + }; + + useImperativeHandle(ref, () => { + return { + getFieldsValue: () => { + return formInst.getFieldsValue(true); + }, + resetFields: (fields) => { + return formInst.resetFields(fields as (keyof MonitorNodeConfigFormFieldValues)[]); + }, + validateFields: (nameList, config) => { + return formInst.validateFields(nameList, config); + }, + } as MonitorNodeConfigFormInstance; + }); + + return ( +
+ + } /> + + +
+
+ + + +
+ +
+ + + +
+
+ + + + + + + + +
+ ); + } +); + +export default memo(MonitorNodeConfigForm); diff --git a/ui/src/components/workflow/node/NotifyNode.tsx b/ui/src/components/workflow/node/NotifyNode.tsx index 16132539..da48552d 100644 --- a/ui/src/components/workflow/node/NotifyNode.tsx +++ b/ui/src/components/workflow/node/NotifyNode.tsx @@ -43,7 +43,7 @@ const NotifyNode = ({ node, disabled }: NotifyNodeProps) => { const provider = notificationProvidersMap.get(config.provider); return ( - + {t(channel?.name ?? provider?.name ?? " ")} {config.subject ?? ""} diff --git a/ui/src/components/workflow/node/UnknownNode.tsx b/ui/src/components/workflow/node/UnknownNode.tsx new file mode 100644 index 00000000..7cb64aae --- /dev/null +++ b/ui/src/components/workflow/node/UnknownNode.tsx @@ -0,0 +1,45 @@ +import { memo } from "react"; +import { CloseCircleOutlined as CloseCircleOutlinedIcon } from "@ant-design/icons"; +import { Alert, Button, Card } from "antd"; + +import { useZustandShallowSelector } from "@/hooks"; +import { useWorkflowStore } from "@/stores/workflow"; + +import { type SharedNodeProps } from "./_SharedNode"; +import AddNode from "./AddNode"; + +export type MonitorNodeProps = SharedNodeProps; + +const UnknownNode = ({ node, disabled }: MonitorNodeProps) => { + const { removeNode } = useWorkflowStore(useZustandShallowSelector(["removeNode"])); + + const handleClickRemove = () => { + removeNode(node.id); + }; + + return ( + <> + +
+ +
+ INVALID NODE +
+ PLEASE REMOVE +
+
+ } + /> +
+ + + + + ); +}; + +export default memo(UnknownNode); diff --git a/ui/src/domain/provider.ts b/ui/src/domain/provider.ts index 594674f1..bb550691 100644 --- a/ui/src/domain/provider.ts +++ b/ui/src/domain/provider.ts @@ -553,6 +553,14 @@ export const deploymentProvidersMap: Map [ type, diff --git a/ui/src/domain/workflow.ts b/ui/src/domain/workflow.ts index 5a3e9821..4dea7f64 100644 --- a/ui/src/domain/workflow.ts +++ b/ui/src/domain/workflow.ts @@ -31,7 +31,7 @@ export enum WorkflowNodeType { End = "end", Apply = "apply", Upload = "upload", - Inspect = "inspect", + Monitor = "monitor", Deploy = "deploy", Notify = "notify", Branch = "branch", @@ -43,23 +43,25 @@ export enum WorkflowNodeType { } 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.Inspect, i18n.t("workflow_node.inspect.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")], - [WorkflowNodeType.Condition, i18n.t("workflow_node.condition.label")], - [WorkflowNodeType.ExecuteResultBranch, i18n.t("workflow_node.execute_result_branch.label")], - [WorkflowNodeType.ExecuteSuccess, i18n.t("workflow_node.execute_success.label")], - [WorkflowNodeType.ExecuteFailure, i18n.t("workflow_node.execute_failure.label")], - [WorkflowNodeType.Custom, i18n.t("workflow_node.custom.title")], + [WorkflowNodeType.Start, i18n.t("workflow_node.start.default_name")], + [WorkflowNodeType.End, i18n.t("workflow_node.end.default_name")], + [WorkflowNodeType.Apply, i18n.t("workflow_node.apply.default_name")], + [WorkflowNodeType.Upload, i18n.t("workflow_node.upload.default_name")], + [WorkflowNodeType.Monitor, i18n.t("workflow_node.monitor.default_name")], + [WorkflowNodeType.Deploy, i18n.t("workflow_node.deploy.default_name")], + [WorkflowNodeType.Notify, i18n.t("workflow_node.notify.default_name")], + [WorkflowNodeType.Branch, i18n.t("workflow_node.branch.default_name")], + [WorkflowNodeType.Condition, i18n.t("workflow_node.condition.default_name")], + [WorkflowNodeType.ExecuteResultBranch, i18n.t("workflow_node.execute_result_branch.default_name")], + [WorkflowNodeType.ExecuteSuccess, i18n.t("workflow_node.execute_success.default_name")], + [WorkflowNodeType.ExecuteFailure, i18n.t("workflow_node.execute_failure.default_name")], + [WorkflowNodeType.Custom, i18n.t("workflow_node.custom.default_name")], ]); const workflowNodeTypeDefaultInputs: Map = new Map([ [WorkflowNodeType.Apply, []], + [WorkflowNodeType.Upload, []], + [WorkflowNodeType.Monitor, []], [ WorkflowNodeType.Deploy, [ @@ -98,7 +100,7 @@ const workflowNodeTypeDefaultOutputs: Map = ], ], [ - WorkflowNodeType.Inspect, + WorkflowNodeType.Monitor, [ { name: "certificate", @@ -158,11 +160,11 @@ export type WorkflowNodeConfigForUpload = { privateKey: string; }; -export type WorkflowNodeConfigForInspect = { - domain: string; - port: string; +export type WorkflowNodeConfigForMonitor = { host: string; - path: string; + port: number; + domain?: string; + requestPath?: string; }; export type WorkflowNodeConfigForDeploy = { @@ -351,7 +353,7 @@ export const newNode = (nodeType: WorkflowNodeType, options: NewNodeOptions = {} case WorkflowNodeType.Apply: case WorkflowNodeType.Upload: case WorkflowNodeType.Deploy: - case WorkflowNodeType.Inspect: + case WorkflowNodeType.Monitor: { node.inputs = workflowNodeTypeDefaultInputs.get(nodeType); node.outputs = workflowNodeTypeDefaultOutputs.get(nodeType); diff --git a/ui/src/i18n/locales/en/nls.workflow.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json index edd703f2..5b6c870c 100644 --- a/ui/src/i18n/locales/en/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/en/nls.workflow.nodes.json @@ -10,6 +10,7 @@ "workflow_node.unsaved_changes.confirm": "You have unsaved changes. Do you really want to close the panel and drop those changes?", "workflow_node.start.label": "Start", + "workflow_node.start.default_name": "Start", "workflow_node.start.form.trigger.label": "Trigger", "workflow_node.start.form.trigger.placeholder": "Please select trigger", "workflow_node.start.form.trigger.tooltip": "Auto: Time triggered based on cron expression.
Manual: Manually triggered.", @@ -22,7 +23,8 @@ "workflow_node.start.form.trigger_cron.extra": "Expected execution time for the last 5 times:", "workflow_node.start.form.trigger_cron.guide": "Tips: If you have multiple workflows, it is recommended to set them to run at multiple times of the day instead of always running at specific times. Don't always set it to midnight every day to avoid spikes in traffic.

Reference links:
1. Let’s Encrypt rate limits
2. Why should my Let’s Encrypt (ACME) client run at a random time?", - "workflow_node.apply.label": "Application", + "workflow_node.apply.label": "Obtain certificate", + "workflow_node.apply.default_name": "Application", "workflow_node.apply.form.domains.label": "Domains", "workflow_node.apply.form.domains.placeholder": "Please enter domains (separated by semicolons)", "workflow_node.apply.form.domains.tooltip": "Wildcard domain: *.example.com", @@ -97,7 +99,17 @@ "workflow_node.apply.form.skip_before_expiry_days.unit": "days", "workflow_node.apply.form.skip_before_expiry_days.tooltip": "Be careful not to exceed the validity period limit of the issued certificate, otherwise the certificate may never be renewed.", - "workflow_node.deploy.label": "Deployment", + "workflow_node.upload.label": "Upload certificate", + "workflow_node.upload.default_name": "Uploading", + "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.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.deploy.label": "Deploy certificate", + "workflow_node.deploy.default_name": "Deployment", "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 ...", @@ -805,25 +817,20 @@ "workflow_node.deploy.form.skip_on_last_succeeded.switch.on": "skip", "workflow_node.deploy.form.skip_on_last_succeeded.switch.off": "not skip", - "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.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.monitor.label": "Monitor certificate", + "workflow_node.monitor.default_name": "Monitoring", + "workflow_node.monitor.form.guide": "Tips: Certimate will send a HEAD request to the target address to obtain the certificate. Please ensure that the address is accessible through HTTPS protocol.", + "workflow_node.monitor.form.host.label": "Host", + "workflow_node.monitor.form.host.placeholder": "Please enter host", + "workflow_node.monitor.form.port.label": "Port", + "workflow_node.monitor.form.port.placeholder": "Please enter port", + "workflow_node.monitor.form.domain.label": "Domain (Optional)", + "workflow_node.monitor.form.domain.placeholder": "Please enter domain name", + "workflow_node.monitor.form.request_path.label": "Request path (Optional)", + "workflow_node.monitor.form.request_path.placeholder": "Please enter request path", - "workflow_node.inspect.label": "Inspect certificate", - "workflow_node.inspect.form.domain.label": "Domain", - "workflow_node.inspect.form.domain.placeholder": "Please enter domain name", - "workflow_node.inspect.form.port.label": "Port", - "workflow_node.inspect.form.port.placeholder": "Please enter port", - "workflow_node.inspect.form.host.label": "Host", - "workflow_node.inspect.form.host.placeholder": "Please enter host", - "workflow_node.inspect.form.path.label": "Path", - "workflow_node.inspect.form.path.placeholder": "Please enter path", - - "workflow_node.notify.label": "Notification", + "workflow_node.notify.label": "Send notification", + "workflow_node.notify.default_name": "Notification", "workflow_node.notify.form.subject.label": "Subject", "workflow_node.notify.form.subject.placeholder": "Please enter subject", "workflow_node.notify.form.message.label": "Message", @@ -862,10 +869,13 @@ "workflow_node.notify.form.webhook_data.errmsg.json_invalid": "Please enter a valiod JSON string", "workflow_node.end.label": "End", + "workflow_node.end.default_name": "End", "workflow_node.branch.label": "Parallel branch", + "workflow_node.branch.default_name": "Parallel", "workflow_node.condition.label": "Branch", + "workflow_node.condition.default_name": "Branch", "workflow_node.condition.form.variable.placeholder": "Please select variable", "workflow_node.condition.form.variable.errmsg": "Please select variable", "workflow_node.condition.form.operator.errmsg": "Please select operator", @@ -888,8 +898,11 @@ "workflow_node.condition.form.comparison.is": "Is", "workflow_node.execute_result_branch.label": "Execution result branch", + "workflow_node.execute_result_branch.default_name": "Execution result branch", "workflow_node.execute_success.label": "If the previous node succeeded ...", + "workflow_node.execute_success.default_name": "If the previous node succeeded ...", - "workflow_node.execute_failure.label": "If the previous node failed ..." + "workflow_node.execute_failure.label": "If the previous node failed ...", + "workflow_node.execute_failure.default_name": "If the previous node failed ..." } diff --git a/ui/src/i18n/locales/zh/nls.access.json b/ui/src/i18n/locales/zh/nls.access.json index fb51668f..5f28a950 100644 --- a/ui/src/i18n/locales/zh/nls.access.json +++ b/ui/src/i18n/locales/zh/nls.access.json @@ -28,7 +28,7 @@ "access.form.name.placeholder": "请输入授权名称", "access.form.provider.label": "提供商", "access.form.provider.placeholder": "请选择提供商", - "access.form.provider.tooltip": "提供商分为两种类型:
【DNS 提供商】你的 DNS 托管方,通常等同于域名注册商,用于在申请证书时管理您的域名解析记录。
【主机提供商】你的服务器或云服务的托管方,用于部署签发的证书。

该字段保存后不可修改。", + "access.form.provider.tooltip": "提供商分为两种类型:
【DNS 提供商】你的 DNS 托管方,通常等同于域名注册商,用于在申请证书时管理你的域名解析记录。
【主机提供商】你的服务器或云服务的托管方,用于部署签发的证书。

该字段保存后不可修改。", "access.form.provider.search.placeholder": "搜索提供商……", "access.form.certificate_authority.label": "证书颁发机构", "access.form.certificate_authority.placeholder": "请选择证书颁发机构", diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index ef61e5a5..0d7ce68c 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -10,6 +10,7 @@ "workflow_node.unsaved_changes.confirm": "你有尚未保存的更改。确定要关闭面板吗?", "workflow_node.start.label": "开始", + "workflow_node.start.default_name": "开始", "workflow_node.start.form.trigger.label": "触发方式", "workflow_node.start.form.trigger.placeholder": "请选择触发方式", "workflow_node.start.form.trigger.tooltip": "自动触发:基于 Cron 表达式定时触发。
手动触发:手动点击执行触发。", @@ -22,7 +23,8 @@ "workflow_node.start.form.trigger_cron.extra": "预计最近 5 次执行时间:", "workflow_node.start.form.trigger_cron.guide": "小贴士:如果你有多个工作流,建议将它们设置为在一天中的多个时间段运行,而非总是在相同的特定时间。也不要总是设置为每日零时,以免遭遇证书颁发机构的流量高峰。

参考链接:
1. Let’s Encrypt 速率限制
2. 为什么我的 Let’s Encrypt (ACME) 客户端启动时间应当随机?", - "workflow_node.apply.label": "申请证书", + "workflow_node.apply.label": "申请签发证书", + "workflow_node.apply.default_name": "申请", "workflow_node.apply.form.domains.label": "域名", "workflow_node.apply.form.domains.placeholder": "请输入域名(多个值请用半角分号隔开)", "workflow_node.apply.form.domains.tooltip": "泛域名表示形式为:*.example.com", @@ -96,7 +98,17 @@ "workflow_node.apply.form.skip_before_expiry_days.unit": "天", "workflow_node.apply.form.skip_before_expiry_days.tooltip": "注意不要超过颁发的证书最大有效期,否则证书可能永远不会续期。", - "workflow_node.deploy.label": "部署证书", + "workflow_node.upload.label": "上传自有证书", + "workflow_node.upload.default_name": "上传", + "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.private_key.label": "私钥文件(PEM 格式)", + "workflow_node.upload.form.private_key.placeholder": "-----BEGIN (RSA|EC) PRIVATE KEY-----...-----END(RSA|EC) PRIVATE KEY-----", + + "workflow_node.deploy.label": "部署证书到 ...", + "workflow_node.deploy.default_name": "部署", "workflow_node.deploy.form.provider.label": "部署目标", "workflow_node.deploy.form.provider.placeholder": "请选择部署目标", "workflow_node.deploy.form.provider.search.placeholder": "搜索部署目标……", @@ -804,25 +816,20 @@ "workflow_node.deploy.form.skip_on_last_succeeded.switch.on": "跳过", "workflow_node.deploy.form.skip_on_last_succeeded.switch.off": "不跳过", - "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.private_key.label": "私钥文件(PEM 格式)", - "workflow_node.upload.form.private_key.placeholder": "-----BEGIN (RSA|EC) PRIVATE KEY-----...-----END(RSA|EC) PRIVATE KEY-----", - - "workflow_node.inspect.label": "检查网站证书", - "workflow_node.inspect.form.domain.label": "域名", - "workflow_node.inspect.form.domain.placeholder": "请输入要检查的网站域名", - "workflow_node.inspect.form.port.label": "端口号", - "workflow_node.inspect.form.port.placeholder": "请输入要检查的端口号", - "workflow_node.inspect.form.host.label": "Host", - "workflow_node.inspect.form.host.placeholder": "请输入 Host", - "workflow_node.inspect.form.path.label": "Path", - "workflow_node.inspect.form.path.placeholder": "请输入 Path", + "workflow_node.monitor.label": "监控网站证书", + "workflow_node.monitor.default_name": "监控", + "workflow_node.monitor.form.guide": "小贴士:Certimate 将向目标地址发送一个 HEAD 请求来获取相应的域名证书,请确保该地址可通过 HTTPS 协议访问。", + "workflow_node.monitor.form.host.label": "主机地址", + "workflow_node.monitor.form.host.placeholder": "请输入主机地址(可以是域名或 IP)", + "workflow_node.monitor.form.port.label": "主机端口", + "workflow_node.monitor.form.port.placeholder": "请输入主机端口", + "workflow_node.monitor.form.domain.label": "域名(可选)", + "workflow_node.monitor.form.domain.placeholder": "请输入域名(仅当主机地址为 IP 时可选)", + "workflow_node.monitor.form.request_path.label": "请求路径(可选)", + "workflow_node.monitor.form.request_path.placeholder": "请输入请求路径", "workflow_node.notify.label": "推送通知", + "workflow_node.notify.default_name": "通知", "workflow_node.notify.form.subject.label": "通知主题", "workflow_node.notify.form.subject.placeholder": "请输入通知主题", "workflow_node.notify.form.message.label": "通知内容", @@ -861,10 +868,13 @@ "workflow_node.notify.form.webhook_data.errmsg.json_invalid": "请输入有效的 JSON 格式字符串", "workflow_node.end.label": "结束", + "workflow_node.end.default_name": "结束", "workflow_node.branch.label": "并行分支", + "workflow_node.branch.default_name": "并行", "workflow_node.condition.label": "分支", + "workflow_node.condition.default_name": "分支", "workflow_node.condition.form.variable.placeholder": "选择变量", "workflow_node.condition.form.variable.errmsg": "请选择变量", "workflow_node.condition.form.operator.errmsg": "请选择操作符", @@ -887,8 +897,11 @@ "workflow_node.condition.form.comparison.is": "为", "workflow_node.execute_result_branch.label": "执行结果分支", + "workflow_node.execute_result_branch.default_name": "执行结果分支", "workflow_node.execute_success.label": "若前序节点执行成功…", + "workflow_node.execute_success.default_name": "若前序节点执行成功…", - "workflow_node.execute_failure.label": "若前序节点执行失败…" + "workflow_node.execute_failure.label": "若前序节点执行失败…", + "workflow_node.execute_failure.default_name": "若前序节点执行失败…" } diff --git a/ui/src/pages/accesses/AccessList.tsx b/ui/src/pages/accesses/AccessList.tsx index f815812e..a99dd588 100644 --- a/ui/src/pages/accesses/AccessList.tsx +++ b/ui/src/pages/accesses/AccessList.tsx @@ -56,7 +56,7 @@ const AccessList = () => { render: (_, record) => { return ( - + {t(accessProvidersMap.get(record.provider)?.name ?? "")} ); From 6731c465e7004f0fa0ac8b58cb37ded5e9eb9293 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Wed, 28 May 2025 23:30:38 +0800 Subject: [PATCH 13/14] refactor: workflow condition node refactor: workflow condition node --- internal/applicant/applicant.go | 38 +- internal/deployer/deployer.go | 12 +- internal/domain/{ => expr}/expr.go | 413 ++++++++++-------- internal/domain/{ => expr}/expr_test.go | 30 +- internal/domain/workflow.go | 14 +- internal/notify/notifier.go | 12 +- .../lego-providers/powerdns/powerdns.go | 1 + .../deployer/providers/proxmoxve/proxmoxve.go | 12 +- .../wangsu-certificate/wangsu_certificate.go | 2 +- internal/pkg/utils/http/transport.go | 33 ++ .../workflow/node-processor/apply_node.go | 18 +- .../workflow/node-processor/condition_node.go | 21 +- internal/workflow/node-processor/const.go | 4 +- internal/workflow/node-processor/context.go | 4 +- .../workflow/node-processor/deploy_node.go | 5 +- .../workflow/node-processor/monitor_node.go | 77 ++-- .../workflow/node-processor/notify_node.go | 14 +- .../workflow/node-processor/upload_node.go | 19 +- main.go | 9 +- ui/src/components/MultipleInput.tsx | 40 +- ui/src/components/MultipleSplitValueInput.tsx | 6 +- .../components/access/AccessFormSSHConfig.tsx | 2 +- ui/src/components/access/AccessSelect.tsx | 10 +- .../provider/ACMEDns01ProviderSelect.tsx | 12 +- .../provider/AccessProviderSelect.tsx | 12 +- .../components/provider/CAProviderSelect.tsx | 12 +- .../provider/DeploymentProviderSelect.tsx | 12 +- .../provider/NotificationProviderSelect.tsx | 12 +- .../components/workflow/WorkflowRunDetail.tsx | 2 +- ui/src/components/workflow/node/AddNode.tsx | 9 +- ui/src/components/workflow/node/ApplyNode.tsx | 4 +- .../workflow/node/ApplyNodeConfigForm.tsx | 6 +- .../workflow/node/ConditionNode.tsx | 101 ++--- .../workflow/node/ConditionNodeConfigForm.tsx | 348 ++------------- ...onditionNodeConfigFormExpressionEditor.tsx | 400 +++++++++++++++++ .../components/workflow/node/DeployNode.tsx | 6 +- .../workflow/node/DeployNodeConfigForm.tsx | 51 ++- ...loyNodeConfigFormAliyunCASDeployConfig.tsx | 6 +- ...ployNodeConfigFormBaotaPanelSiteConfig.tsx | 4 +- ...eConfigFormTencentCloudSSLDeployConfig.tsx | 4 +- ...loyNodeConfigFormUniCloudWebHostConfig.tsx | 6 +- .../DeployNodeConfigFormWangsuCDNConfig.tsx | 4 +- .../components/workflow/node/MonitorNode.tsx | 4 +- .../components/workflow/node/NotifyNode.tsx | 4 +- ui/src/components/workflow/node/StartNode.tsx | 4 +- .../components/workflow/node/UploadNode.tsx | 4 +- .../components/workflow/node/_SharedNode.tsx | 21 +- ui/src/domain/workflow.ts | 145 ++---- ui/src/domain/workflowExpr.ts | 1 + ui/src/i18n/locales/en/index.ts | 4 +- ui/src/i18n/locales/en/nls.workflow.json | 6 +- .../i18n/locales/en/nls.workflow.nodes.json | 45 +- ui/src/i18n/locales/en/nls.workflow.vars.json | 6 + ui/src/i18n/locales/zh/index.ts | 4 +- ui/src/i18n/locales/zh/nls.workflow.json | 6 +- .../i18n/locales/zh/nls.workflow.nodes.json | 53 +-- ui/src/i18n/locales/zh/nls.workflow.vars.json | 6 + ui/src/pages/workflows/WorkflowDetail.tsx | 2 +- ui/src/stores/workflow/index.ts | 6 +- 59 files changed, 1140 insertions(+), 988 deletions(-) rename internal/domain/{ => expr}/expr.go (69%) rename internal/domain/{ => expr}/expr_test.go (66%) create mode 100644 internal/pkg/utils/http/transport.go create mode 100644 ui/src/components/workflow/node/ConditionNodeConfigFormExpressionEditor.tsx create mode 100644 ui/src/domain/workflowExpr.ts create mode 100644 ui/src/i18n/locales/en/nls.workflow.vars.json create mode 100644 ui/src/i18n/locales/zh/nls.workflow.vars.json diff --git a/internal/applicant/applicant.go b/internal/applicant/applicant.go index f1200094..d361cf83 100644 --- a/internal/applicant/applicant.go +++ b/internal/applicant/applicant.go @@ -53,35 +53,35 @@ func NewWithWorkflowNode(config ApplicantWithWorkflowNodeConfig) (Applicant, err return nil, fmt.Errorf("node type is not '%s'", string(domain.WorkflowNodeTypeApply)) } - nodeConfig := config.Node.GetConfigForApply() + nodeCfg := config.Node.GetConfigForApply() options := &applicantProviderOptions{ - Domains: sliceutil.Filter(strings.Split(nodeConfig.Domains, ";"), func(s string) bool { return s != "" }), - ContactEmail: nodeConfig.ContactEmail, - Provider: domain.ACMEDns01ProviderType(nodeConfig.Provider), + Domains: sliceutil.Filter(strings.Split(nodeCfg.Domains, ";"), func(s string) bool { return s != "" }), + ContactEmail: nodeCfg.ContactEmail, + Provider: domain.ACMEDns01ProviderType(nodeCfg.Provider), ProviderAccessConfig: make(map[string]any), - ProviderServiceConfig: nodeConfig.ProviderConfig, - CAProvider: domain.CAProviderType(nodeConfig.CAProvider), + ProviderServiceConfig: nodeCfg.ProviderConfig, + CAProvider: domain.CAProviderType(nodeCfg.CAProvider), CAProviderAccessConfig: make(map[string]any), - CAProviderServiceConfig: nodeConfig.CAProviderConfig, - KeyAlgorithm: nodeConfig.KeyAlgorithm, - Nameservers: sliceutil.Filter(strings.Split(nodeConfig.Nameservers, ";"), func(s string) bool { return s != "" }), - DnsPropagationWait: nodeConfig.DnsPropagationWait, - DnsPropagationTimeout: nodeConfig.DnsPropagationTimeout, - DnsTTL: nodeConfig.DnsTTL, - DisableFollowCNAME: nodeConfig.DisableFollowCNAME, + CAProviderServiceConfig: nodeCfg.CAProviderConfig, + KeyAlgorithm: nodeCfg.KeyAlgorithm, + Nameservers: sliceutil.Filter(strings.Split(nodeCfg.Nameservers, ";"), func(s string) bool { return s != "" }), + DnsPropagationWait: nodeCfg.DnsPropagationWait, + DnsPropagationTimeout: nodeCfg.DnsPropagationTimeout, + DnsTTL: nodeCfg.DnsTTL, + DisableFollowCNAME: nodeCfg.DisableFollowCNAME, } accessRepo := repository.NewAccessRepository() - if nodeConfig.ProviderAccessId != "" { - if access, err := accessRepo.GetById(context.Background(), nodeConfig.ProviderAccessId); err != nil { - return nil, fmt.Errorf("failed to get access #%s record: %w", nodeConfig.ProviderAccessId, err) + if nodeCfg.ProviderAccessId != "" { + if access, err := accessRepo.GetById(context.Background(), nodeCfg.ProviderAccessId); err != nil { + return nil, fmt.Errorf("failed to get access #%s record: %w", nodeCfg.ProviderAccessId, err) } else { options.ProviderAccessConfig = access.Config } } - if nodeConfig.CAProviderAccessId != "" { - if access, err := accessRepo.GetById(context.Background(), nodeConfig.CAProviderAccessId); err != nil { - return nil, fmt.Errorf("failed to get access #%s record: %w", nodeConfig.CAProviderAccessId, err) + if nodeCfg.CAProviderAccessId != "" { + if access, err := accessRepo.GetById(context.Background(), nodeCfg.CAProviderAccessId); err != nil { + return nil, fmt.Errorf("failed to get access #%s record: %w", nodeCfg.CAProviderAccessId, err) } else { options.CAProviderAccessId = access.Id options.CAProviderAccessConfig = access.Config diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index e4a28746..c73120ba 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -29,18 +29,18 @@ func NewWithWorkflowNode(config DeployerWithWorkflowNodeConfig) (Deployer, error return nil, fmt.Errorf("node type is not '%s'", string(domain.WorkflowNodeTypeDeploy)) } - nodeConfig := config.Node.GetConfigForDeploy() + nodeCfg := config.Node.GetConfigForDeploy() options := &deployerProviderOptions{ - Provider: domain.DeploymentProviderType(nodeConfig.Provider), + Provider: domain.DeploymentProviderType(nodeCfg.Provider), ProviderAccessConfig: make(map[string]any), - ProviderServiceConfig: nodeConfig.ProviderConfig, + ProviderServiceConfig: nodeCfg.ProviderConfig, } accessRepo := repository.NewAccessRepository() - if nodeConfig.ProviderAccessId != "" { - access, err := accessRepo.GetById(context.Background(), nodeConfig.ProviderAccessId) + if nodeCfg.ProviderAccessId != "" { + access, err := accessRepo.GetById(context.Background(), nodeCfg.ProviderAccessId) if err != nil { - return nil, fmt.Errorf("failed to get access #%s record: %w", nodeConfig.ProviderAccessId, err) + return nil, fmt.Errorf("failed to get access #%s record: %w", nodeCfg.ProviderAccessId, err) } else { options.ProviderAccessConfig = access.Config } diff --git a/internal/domain/expr.go b/internal/domain/expr/expr.go similarity index 69% rename from internal/domain/expr.go rename to internal/domain/expr/expr.go index 01730e3d..755a876c 100644 --- a/internal/domain/expr.go +++ b/internal/domain/expr/expr.go @@ -1,4 +1,4 @@ -package domain +package expr import ( "encoding/json" @@ -6,41 +6,38 @@ import ( "strconv" ) -type Value any - type ( - ComparisonOperator string - LogicalOperator string - ValueType string - ExprType string + ExprType string + ExprComparisonOperator string + ExprLogicalOperator string + ExprValueType string ) const ( - GreaterThan ComparisonOperator = ">" - LessThan ComparisonOperator = "<" - GreaterOrEqual ComparisonOperator = ">=" - LessOrEqual ComparisonOperator = "<=" - Equal ComparisonOperator = "==" - NotEqual ComparisonOperator = "!=" - Is ComparisonOperator = "is" + GreaterThan ExprComparisonOperator = "gt" + GreaterOrEqual ExprComparisonOperator = "gte" + LessThan ExprComparisonOperator = "lt" + LessOrEqual ExprComparisonOperator = "lte" + Equal ExprComparisonOperator = "eq" + NotEqual ExprComparisonOperator = "neq" - And LogicalOperator = "and" - Or LogicalOperator = "or" - Not LogicalOperator = "not" + And ExprLogicalOperator = "and" + Or ExprLogicalOperator = "or" + Not ExprLogicalOperator = "not" - Number ValueType = "number" - String ValueType = "string" - Boolean ValueType = "boolean" + Number ExprValueType = "number" + String ExprValueType = "string" + Boolean ExprValueType = "boolean" - ConstExprType ExprType = "const" - VarExprType ExprType = "var" - CompareExprType ExprType = "compare" - LogicalExprType ExprType = "logical" - NotExprType ExprType = "not" + ConstantExprType ExprType = "const" + VariantExprType ExprType = "var" + ComparisonExprType ExprType = "comparison" + LogicalExprType ExprType = "logical" + NotExprType ExprType = "not" ) type EvalResult struct { - Type ValueType + Type ExprValueType Value any } @@ -88,13 +85,20 @@ func (e *EvalResult) GreaterThan(other *EvalResult) (*EvalResult, error) { if e.Type != other.Type { return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) } - switch e.Type { - case Number: + switch e.Type { + case String: + return &EvalResult{ + Type: Boolean, + Value: e.Value.(string) > other.Value.(string), + }, nil + + case Number: left, err := e.GetFloat64() if err != nil { return nil, err } + right, err := other.GetFloat64() if err != nil { return nil, err @@ -104,14 +108,9 @@ func (e *EvalResult) GreaterThan(other *EvalResult) (*EvalResult, error) { Type: Boolean, Value: left > right, }, nil - case String: - return &EvalResult{ - Type: Boolean, - Value: e.Value.(string) > other.Value.(string), - }, nil default: - return nil, fmt.Errorf("unsupported type: %s", e.Type) + return nil, fmt.Errorf("unsupported value type: %s", e.Type) } } @@ -119,28 +118,32 @@ func (e *EvalResult) GreaterOrEqual(other *EvalResult) (*EvalResult, error) { if e.Type != other.Type { return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) } + switch e.Type { - case Number: - left, err := e.GetFloat64() - if err != nil { - return nil, err - } - right, err := other.GetFloat64() - if err != nil { - return nil, err - } - return &EvalResult{ - Type: Boolean, - Value: left >= right, - }, nil case String: return &EvalResult{ Type: Boolean, Value: e.Value.(string) >= other.Value.(string), }, nil + case Number: + left, err := e.GetFloat64() + if err != nil { + return nil, err + } + + right, err := other.GetFloat64() + if err != nil { + return nil, err + } + + return &EvalResult{ + Type: Boolean, + Value: left >= right, + }, nil + default: - return nil, fmt.Errorf("unsupported type: %s", e.Type) + return nil, fmt.Errorf("unsupported value type: %s", e.Type) } } @@ -148,28 +151,32 @@ func (e *EvalResult) LessThan(other *EvalResult) (*EvalResult, error) { if e.Type != other.Type { return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) } + switch e.Type { - case Number: - left, err := e.GetFloat64() - if err != nil { - return nil, err - } - right, err := other.GetFloat64() - if err != nil { - return nil, err - } - return &EvalResult{ - Type: Boolean, - Value: left < right, - }, nil case String: return &EvalResult{ Type: Boolean, Value: e.Value.(string) < other.Value.(string), }, nil + case Number: + left, err := e.GetFloat64() + if err != nil { + return nil, err + } + + right, err := other.GetFloat64() + if err != nil { + return nil, err + } + + return &EvalResult{ + Type: Boolean, + Value: left < right, + }, nil + default: - return nil, fmt.Errorf("unsupported type: %s", e.Type) + return nil, fmt.Errorf("unsupported value type: %s", e.Type) } } @@ -177,28 +184,32 @@ func (e *EvalResult) LessOrEqual(other *EvalResult) (*EvalResult, error) { if e.Type != other.Type { return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) } + switch e.Type { - case Number: - left, err := e.GetFloat64() - if err != nil { - return nil, err - } - right, err := other.GetFloat64() - if err != nil { - return nil, err - } - return &EvalResult{ - Type: Boolean, - Value: left <= right, - }, nil case String: return &EvalResult{ Type: Boolean, Value: e.Value.(string) <= other.Value.(string), }, nil + case Number: + left, err := e.GetFloat64() + if err != nil { + return nil, err + } + + right, err := other.GetFloat64() + if err != nil { + return nil, err + } + + return &EvalResult{ + Type: Boolean, + Value: left <= right, + }, nil + default: - return nil, fmt.Errorf("unsupported type: %s", e.Type) + return nil, fmt.Errorf("unsupported value type: %s", e.Type) } } @@ -206,28 +217,48 @@ func (e *EvalResult) Equal(other *EvalResult) (*EvalResult, error) { if e.Type != other.Type { return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) } + switch e.Type { - case Number: - left, err := e.GetFloat64() - if err != nil { - return nil, err - } - right, err := other.GetFloat64() - if err != nil { - return nil, err - } - return &EvalResult{ - Type: Boolean, - Value: left == right, - }, nil case String: return &EvalResult{ Type: Boolean, Value: e.Value.(string) == other.Value.(string), }, nil + case Number: + left, err := e.GetFloat64() + if err != nil { + return nil, err + } + + right, err := other.GetFloat64() + if err != nil { + return nil, err + } + + return &EvalResult{ + Type: Boolean, + Value: left == right, + }, nil + + case Boolean: + left, err := e.GetBool() + if err != nil { + return nil, err + } + + right, err := other.GetBool() + if err != nil { + return nil, err + } + + return &EvalResult{ + Type: Boolean, + Value: left == right, + }, nil + default: - return nil, fmt.Errorf("unsupported type: %s", e.Type) + return nil, fmt.Errorf("unsupported value type: %s", e.Type) } } @@ -235,28 +266,48 @@ func (e *EvalResult) NotEqual(other *EvalResult) (*EvalResult, error) { if e.Type != other.Type { return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) } + switch e.Type { - case Number: - left, err := e.GetFloat64() - if err != nil { - return nil, err - } - right, err := other.GetFloat64() - if err != nil { - return nil, err - } - return &EvalResult{ - Type: Boolean, - Value: left != right, - }, nil case String: return &EvalResult{ Type: Boolean, Value: e.Value.(string) != other.Value.(string), }, nil + case Number: + left, err := e.GetFloat64() + if err != nil { + return nil, err + } + + right, err := other.GetFloat64() + if err != nil { + return nil, err + } + + return &EvalResult{ + Type: Boolean, + Value: left != right, + }, nil + + case Boolean: + left, err := e.GetBool() + if err != nil { + return nil, err + } + + right, err := other.GetBool() + if err != nil { + return nil, err + } + + return &EvalResult{ + Type: Boolean, + Value: left != right, + }, nil + default: - return nil, fmt.Errorf("unsupported type: %s", e.Type) + return nil, fmt.Errorf("unsupported value type: %s", e.Type) } } @@ -264,22 +315,26 @@ func (e *EvalResult) And(other *EvalResult) (*EvalResult, error) { if e.Type != other.Type { return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) } + switch e.Type { case Boolean: left, err := e.GetBool() if err != nil { return nil, err } + right, err := other.GetBool() if err != nil { return nil, err } + return &EvalResult{ Type: Boolean, Value: left && right, }, nil + default: - return nil, fmt.Errorf("unsupported type: %s", e.Type) + return nil, fmt.Errorf("unsupported value type: %s", e.Type) } } @@ -287,22 +342,25 @@ func (e *EvalResult) Or(other *EvalResult) (*EvalResult, error) { if e.Type != other.Type { return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) } + switch e.Type { case Boolean: left, err := e.GetBool() if err != nil { return nil, err } + right, err := other.GetBool() if err != nil { return nil, err } + return &EvalResult{ Type: Boolean, Value: left || right, }, nil default: - return nil, fmt.Errorf("unsupported type: %s", e.Type) + return nil, fmt.Errorf("unsupported value type: %s", e.Type) } } @@ -310,67 +368,52 @@ func (e *EvalResult) Not() (*EvalResult, error) { if e.Type != Boolean { return nil, fmt.Errorf("type mismatch: %s", e.Type) } + boolValue, err := e.GetBool() if err != nil { return nil, err } + return &EvalResult{ Type: Boolean, Value: !boolValue, }, nil } -func (e *EvalResult) Is(other *EvalResult) (*EvalResult, error) { - if e.Type != other.Type { - return nil, fmt.Errorf("type mismatch: %s vs %s", e.Type, other.Type) - } - switch e.Type { - case Boolean: - left, err := e.GetBool() - if err != nil { - return nil, err - } - right, err := other.GetBool() - if err != nil { - return nil, err - } - return &EvalResult{ - Type: Boolean, - Value: left == right, - }, nil - default: - return nil, fmt.Errorf("unsupported type: %s", e.Type) - } -} - type Expr interface { GetType() ExprType Eval(variables map[string]map[string]any) (*EvalResult, error) } -type ConstExpr struct { - Type ExprType `json:"type"` - Value Value `json:"value"` - ValueType ValueType `json:"valueType"` +type ExprValueSelector struct { + Id string `json:"id"` + Name string `json:"name"` + Type ExprValueType `json:"type"` } -func (c ConstExpr) GetType() ExprType { return c.Type } +type ConstantExpr struct { + Type ExprType `json:"type"` + Value string `json:"value"` + ValueType ExprValueType `json:"valueType"` +} -func (c ConstExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) { +func (c ConstantExpr) GetType() ExprType { return c.Type } + +func (c ConstantExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) { return &EvalResult{ Type: c.ValueType, Value: c.Value, }, nil } -type VarExpr struct { - Type ExprType `json:"type"` - Selector WorkflowNodeIOValueSelector `json:"selector"` +type VariantExpr struct { + Type ExprType `json:"type"` + Selector ExprValueSelector `json:"selector"` } -func (v VarExpr) GetType() ExprType { return v.Type } +func (v VariantExpr) GetType() ExprType { return v.Type } -func (v VarExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) { +func (v VariantExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) { if v.Selector.Id == "" { return nil, fmt.Errorf("node id is empty") } @@ -391,16 +434,16 @@ func (v VarExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) }, nil } -type CompareExpr struct { - Type ExprType `json:"type"` // compare - Op ComparisonOperator `json:"op"` - Left Expr `json:"left"` - Right Expr `json:"right"` +type ComparisonExpr struct { + Type ExprType `json:"type"` // compare + Operator ExprComparisonOperator `json:"operator"` + Left Expr `json:"left"` + Right Expr `json:"right"` } -func (c CompareExpr) GetType() ExprType { return c.Type } +func (c ComparisonExpr) GetType() ExprType { return c.Type } -func (c CompareExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) { +func (c ComparisonExpr) Eval(variables map[string]map[string]any) (*EvalResult, error) { left, err := c.Left.Eval(variables) if err != nil { return nil, err @@ -410,7 +453,7 @@ func (c CompareExpr) Eval(variables map[string]map[string]any) (*EvalResult, err return nil, err } - switch c.Op { + switch c.Operator { case GreaterThan: return left.GreaterThan(right) case LessThan: @@ -423,18 +466,16 @@ func (c CompareExpr) Eval(variables map[string]map[string]any) (*EvalResult, err return left.Equal(right) case NotEqual: return left.NotEqual(right) - case Is: - return left.Is(right) default: - return nil, fmt.Errorf("unknown operator: %s", c.Op) + return nil, fmt.Errorf("unknown expression operator: %s", c.Operator) } } type LogicalExpr struct { - Type ExprType `json:"type"` // logical - Op LogicalOperator `json:"op"` - Left Expr `json:"left"` - Right Expr `json:"right"` + Type ExprType `json:"type"` // logical + Operator ExprLogicalOperator `json:"operator"` + Left Expr `json:"left"` + Right Expr `json:"right"` } func (l LogicalExpr) GetType() ExprType { return l.Type } @@ -449,13 +490,13 @@ func (l LogicalExpr) Eval(variables map[string]map[string]any) (*EvalResult, err return nil, err } - switch l.Op { + switch l.Operator { case And: return left.And(right) case Or: return left.Or(right) default: - return nil, fmt.Errorf("unknown operator: %s", l.Op) + return nil, fmt.Errorf("unknown expression operator: %s", l.Operator) } } @@ -489,24 +530,24 @@ func UnmarshalExpr(data []byte) (Expr, error) { } switch typ.Type { - case ConstExprType: - var e ConstExpr + case ConstantExprType: + var e ConstantExpr if err := json.Unmarshal(data, &e); err != nil { return nil, err } return e, nil - case VarExprType: - var e VarExpr + case VariantExprType: + var e VariantExpr if err := json.Unmarshal(data, &e); err != nil { return nil, err } return e, nil - case CompareExprType: - var e CompareExprRaw + case ComparisonExprType: + var e ComparisonExprRaw if err := json.Unmarshal(data, &e); err != nil { return nil, err } - return e.ToCompareExpr() + return e.ToComparisonExpr() case LogicalExprType: var e LogicalExprRaw if err := json.Unmarshal(data, &e); err != nil { @@ -520,39 +561,39 @@ func UnmarshalExpr(data []byte) (Expr, error) { } return e.ToNotExpr() default: - return nil, fmt.Errorf("unknown expr type: %s", typ.Type) + return nil, fmt.Errorf("unknown expression type: %s", typ.Type) } } -type CompareExprRaw struct { - Type ExprType `json:"type"` - Op ComparisonOperator `json:"op"` - Left json.RawMessage `json:"left"` - Right json.RawMessage `json:"right"` +type ComparisonExprRaw struct { + Type ExprType `json:"type"` + Operator ExprComparisonOperator `json:"operator"` + Left json.RawMessage `json:"left"` + Right json.RawMessage `json:"right"` } -func (r CompareExprRaw) ToCompareExpr() (CompareExpr, error) { +func (r ComparisonExprRaw) ToComparisonExpr() (ComparisonExpr, error) { leftExpr, err := UnmarshalExpr(r.Left) if err != nil { - return CompareExpr{}, err + return ComparisonExpr{}, err } rightExpr, err := UnmarshalExpr(r.Right) if err != nil { - return CompareExpr{}, err + return ComparisonExpr{}, err } - return CompareExpr{ - Type: r.Type, - Op: r.Op, - Left: leftExpr, - Right: rightExpr, + return ComparisonExpr{ + Type: r.Type, + Operator: r.Operator, + Left: leftExpr, + Right: rightExpr, }, nil } type LogicalExprRaw struct { - Type ExprType `json:"type"` - Op LogicalOperator `json:"op"` - Left json.RawMessage `json:"left"` - Right json.RawMessage `json:"right"` + Type ExprType `json:"type"` + Operator ExprLogicalOperator `json:"operator"` + Left json.RawMessage `json:"left"` + Right json.RawMessage `json:"right"` } func (r LogicalExprRaw) ToLogicalExpr() (LogicalExpr, error) { @@ -565,10 +606,10 @@ func (r LogicalExprRaw) ToLogicalExpr() (LogicalExpr, error) { return LogicalExpr{}, err } return LogicalExpr{ - Type: r.Type, - Op: r.Op, - Left: left, - Right: right, + Type: r.Type, + Operator: r.Operator, + Left: left, + Right: right, }, nil } diff --git a/internal/domain/expr_test.go b/internal/domain/expr/expr_test.go similarity index 66% rename from internal/domain/expr_test.go rename to internal/domain/expr/expr_test.go index f0a34504..fb76d98c 100644 --- a/internal/domain/expr_test.go +++ b/internal/domain/expr/expr_test.go @@ -1,4 +1,4 @@ -package domain +package expr import ( "testing" @@ -7,15 +7,15 @@ import ( func TestLogicalEval(t *testing.T) { // 测试逻辑表达式 and logicalExpr := LogicalExpr{ - Left: ConstExpr{ + Left: ConstantExpr{ Type: "const", - Value: true, + Value: "true", ValueType: "boolean", }, - Op: And, - Right: ConstExpr{ + Operator: And, + Right: ConstantExpr{ Type: "const", - Value: true, + Value: "true", ValueType: "boolean", }, } @@ -29,15 +29,15 @@ func TestLogicalEval(t *testing.T) { // 测试逻辑表达式 or orExpr := LogicalExpr{ - Left: ConstExpr{ + Left: ConstantExpr{ Type: "const", - Value: true, + Value: "true", ValueType: "boolean", }, - Op: Or, - Right: ConstExpr{ + Operator: Or, + Right: ConstantExpr{ Type: "const", - Value: true, + Value: "true", ValueType: "boolean", }, } @@ -63,7 +63,7 @@ func TestUnmarshalExpr(t *testing.T) { { name: "test1", args: args{ - data: []byte(`{"left":{"left":{"selector":{"id":"ODnYSOXB6HQP2_vz6JcZE","name":"certificate.validated","type":"boolean"},"type":"var"},"op":"is","right":{"type":"const","value":true,"valueType":"boolean"},"type":"compare"},"op":"and","right":{"left":{"selector":{"id":"ODnYSOXB6HQP2_vz6JcZE","name":"certificate.daysLeft","type":"number"},"type":"var"},"op":"==","right":{"type":"const","value":2,"valueType":"number"},"type":"compare"},"type":"logical"}`), + data: []byte(`{"left":{"left":{"selector":{"id":"ODnYSOXB6HQP2_vz6JcZE","name":"certificate.validity","type":"boolean"},"type":"var"},"operator":"is","right":{"type":"const","value":true,"valueType":"boolean"},"type":"comparison"},"operator":"and","right":{"left":{"selector":{"id":"ODnYSOXB6HQP2_vz6JcZE","name":"certificate.daysLeft","type":"number"},"type":"var"},"operator":"eq","right":{"type":"const","value":2,"valueType":"number"},"type":"comparison"},"type":"logical"}`), }, }, } @@ -98,11 +98,11 @@ func TestExpr_Eval(t *testing.T) { args: args{ variables: map[string]map[string]any{ "ODnYSOXB6HQP2_vz6JcZE": { - "certificate.validated": true, - "certificate.daysLeft": 2, + "certificate.validity": true, + "certificate.daysLeft": 2, }, }, - data: []byte(`{"left":{"left":{"selector":{"id":"ODnYSOXB6HQP2_vz6JcZE","name":"certificate.validated","type":"boolean"},"type":"var"},"op":"is","right":{"type":"const","value":true,"valueType":"boolean"},"type":"compare"},"op":"and","right":{"left":{"selector":{"id":"ODnYSOXB6HQP2_vz6JcZE","name":"certificate.daysLeft","type":"number"},"type":"var"},"op":"==","right":{"type":"const","value":2,"valueType":"number"},"type":"compare"},"type":"logical"}`), + data: []byte(`{"left":{"left":{"selector":{"id":"ODnYSOXB6HQP2_vz6JcZE","name":"certificate.validity","type":"boolean"},"type":"var"},"operator":"is","right":{"type":"const","value":true,"valueType":"boolean"},"type":"comparison"},"operator":"and","right":{"left":{"selector":{"id":"ODnYSOXB6HQP2_vz6JcZE","name":"certificate.daysLeft","type":"number"},"type":"var"},"operator":"eq","right":{"type":"const","value":2,"valueType":"number"},"type":"comparison"},"type":"logical"}`), }, }, } diff --git a/internal/domain/workflow.go b/internal/domain/workflow.go index 7d7355c5..02f8b671 100644 --- a/internal/domain/workflow.go +++ b/internal/domain/workflow.go @@ -4,6 +4,7 @@ import ( "encoding/json" "time" + "github.com/usual2970/certimate/internal/domain/expr" maputil "github.com/usual2970/certimate/internal/pkg/utils/map" ) @@ -114,7 +115,7 @@ type WorkflowNodeConfigForNotify struct { } type WorkflowNodeConfigForCondition struct { - Expression Expr `json:"expression"` // 条件表达式 + Expression expr.Expr `json:"expression"` // 条件表达式 } func (n *WorkflowNode) GetConfigForApply() WorkflowNodeConfigForApply { @@ -183,9 +184,8 @@ func (n *WorkflowNode) GetConfigForCondition() WorkflowNodeConfigForCondition { return WorkflowNodeConfigForCondition{} } - raw, _ := json.Marshal(expression) - - expr, err := UnmarshalExpr([]byte(raw)) + exprRaw, _ := json.Marshal(expression) + expr, err := expr.UnmarshalExpr([]byte(exprRaw)) if err != nil { return WorkflowNodeConfigForCondition{} } @@ -204,10 +204,6 @@ type WorkflowNodeIO struct { ValueSelector WorkflowNodeIOValueSelector `json:"valueSelector"` } -type WorkflowNodeIOValueSelector struct { - Id string `json:"id"` - Name string `json:"name"` - Type ValueType `json:"type"` -} +type WorkflowNodeIOValueSelector = expr.ExprValueSelector const WorkflowNodeIONameCertificate string = "certificate" diff --git a/internal/notify/notifier.go b/internal/notify/notifier.go index ee3fbd2f..5e957841 100644 --- a/internal/notify/notifier.go +++ b/internal/notify/notifier.go @@ -29,18 +29,18 @@ func NewWithWorkflowNode(config NotifierWithWorkflowNodeConfig) (Notifier, error return nil, fmt.Errorf("node type is not '%s'", string(domain.WorkflowNodeTypeNotify)) } - nodeConfig := config.Node.GetConfigForNotify() + nodeCfg := config.Node.GetConfigForNotify() options := ¬ifierProviderOptions{ - Provider: domain.NotificationProviderType(nodeConfig.Provider), + Provider: domain.NotificationProviderType(nodeCfg.Provider), ProviderAccessConfig: make(map[string]any), - ProviderServiceConfig: nodeConfig.ProviderConfig, + ProviderServiceConfig: nodeCfg.ProviderConfig, } accessRepo := repository.NewAccessRepository() - if nodeConfig.ProviderAccessId != "" { - access, err := accessRepo.GetById(context.Background(), nodeConfig.ProviderAccessId) + if nodeCfg.ProviderAccessId != "" { + access, err := accessRepo.GetById(context.Background(), nodeCfg.ProviderAccessId) if err != nil { - return nil, fmt.Errorf("failed to get access #%s record: %w", nodeConfig.ProviderAccessId, err) + return nil, fmt.Errorf("failed to get access #%s record: %w", nodeCfg.ProviderAccessId, err) } else { options.ProviderAccessConfig = access.Config } diff --git a/internal/pkg/core/applicant/acme-dns-01/lego-providers/powerdns/powerdns.go b/internal/pkg/core/applicant/acme-dns-01/lego-providers/powerdns/powerdns.go index b34516d4..7c87536c 100644 --- a/internal/pkg/core/applicant/acme-dns-01/lego-providers/powerdns/powerdns.go +++ b/internal/pkg/core/applicant/acme-dns-01/lego-providers/powerdns/powerdns.go @@ -29,6 +29,7 @@ func NewChallengeProvider(config *ChallengeProviderConfig) (challenge.Provider, providerConfig.APIKey = config.ApiKey if config.AllowInsecureConnections { providerConfig.HTTPClient.Transport = &http.Transport{ + Proxy: http.ProxyFromEnvironment, TLSClientConfig: &tls.Config{ InsecureSkipVerify: true, }, diff --git a/internal/pkg/core/deployer/providers/proxmoxve/proxmoxve.go b/internal/pkg/core/deployer/providers/proxmoxve/proxmoxve.go index 349c3a16..0295c7e2 100644 --- a/internal/pkg/core/deployer/providers/proxmoxve/proxmoxve.go +++ b/internal/pkg/core/deployer/providers/proxmoxve/proxmoxve.go @@ -13,6 +13,7 @@ import ( "github.com/luthermonson/go-proxmox" "github.com/usual2970/certimate/internal/pkg/core/deployer" + httputil "github.com/usual2970/certimate/internal/pkg/utils/http" ) type DeployerConfig struct { @@ -101,15 +102,16 @@ func createSdkClient(serverUrl, apiToken, apiTokenSecret string, skipTlsVerify b } httpClient := &http.Client{ - Transport: http.DefaultTransport, + Transport: httputil.NewDefaultTransport(), Timeout: http.DefaultClient.Timeout, } if skipTlsVerify { - httpClient.Transport = &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, + transport := httputil.NewDefaultTransport() + if transport.TLSClientConfig == nil { + transport.TLSClientConfig = &tls.Config{} } + transport.TLSClientConfig.InsecureSkipVerify = true + httpClient.Transport = transport } client := proxmox.NewClient( strings.TrimRight(serverUrl, "/")+"/api2/json", diff --git a/internal/pkg/core/uploader/providers/wangsu-certificate/wangsu_certificate.go b/internal/pkg/core/uploader/providers/wangsu-certificate/wangsu_certificate.go index b512be09..38ddbd46 100644 --- a/internal/pkg/core/uploader/providers/wangsu-certificate/wangsu_certificate.go +++ b/internal/pkg/core/uploader/providers/wangsu-certificate/wangsu_certificate.go @@ -65,7 +65,7 @@ func (u *UploaderProvider) Upload(ctx context.Context, certPEM string, privkeyPE } // 查询证书列表,避免重复上传 - // REF: https://www.wangsu.com/document/api-doc/26426 + // REF: https://www.wangsu.com/document/api-doc/22675?productCode=certificatemanagement listCertificatesResp, err := u.sdkClient.ListCertificates() u.logger.Debug("sdk request 'certificatemanagement.ListCertificates'", slog.Any("response", listCertificatesResp)) if err != nil { diff --git a/internal/pkg/utils/http/transport.go b/internal/pkg/utils/http/transport.go new file mode 100644 index 00000000..ff8c8804 --- /dev/null +++ b/internal/pkg/utils/http/transport.go @@ -0,0 +1,33 @@ +package httputil + +import ( + "net" + "net/http" + "time" +) + +// 创建并返回一个 [http.DefaultTransport] 对象副本。 +// +// 出参: +// - transport: [http.DefaultTransport] 对象副本。 +func NewDefaultTransport() *http.Transport { + if http.DefaultTransport != nil { + if t, ok := http.DefaultTransport.(*http.Transport); ok { + return t.Clone() + } + } + + return &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + DualStack: true, + }).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } +} diff --git a/internal/workflow/node-processor/apply_node.go b/internal/workflow/node-processor/apply_node.go index 321d9fc8..8616fbd9 100644 --- a/internal/workflow/node-processor/apply_node.go +++ b/internal/workflow/node-processor/apply_node.go @@ -3,6 +3,7 @@ package nodeprocessor import ( "context" "fmt" + "strconv" "time" "golang.org/x/exp/maps" @@ -108,15 +109,15 @@ func (n *applyNode) Process(ctx context.Context) error { } } - // 添加中间结果 - n.outputs[outputCertificateValidatedKey] = "true" - n.outputs[outputCertificateDaysLeftKey] = fmt.Sprintf("%d", int(time.Until(certificate.ExpireAt).Hours()/24)) + // 记录中间结果 + n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(true) + n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(time.Until(certificate.ExpireAt).Hours()/24), 10) n.logger.Info("application completed") return nil } -func (n *applyNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (skip bool, reason string) { +func (n *applyNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (_skip bool, _reason string) { if lastOutput != nil && lastOutput.Succeeded { // 比较和上次申请时的关键配置(即影响证书签发的)参数是否一致 currentNodeConfig := n.node.GetConfigForApply() @@ -154,9 +155,12 @@ func (n *applyNode) checkCanSkip(ctx context.Context, lastOutput *domain.Workflo renewalInterval := time.Duration(currentNodeConfig.SkipBeforeExpiryDays) * time.Hour * 24 expirationTime := time.Until(lastCertificate.ExpireAt) if expirationTime > renewalInterval { - n.outputs[outputCertificateValidatedKey] = "true" - n.outputs[outputCertificateDaysLeftKey] = fmt.Sprintf("%d", int(expirationTime.Hours()/24)) - return true, fmt.Sprintf("the certificate has already been issued (expires in %d day(s), next renewal in %d day(s))", int(expirationTime.Hours()/24), currentNodeConfig.SkipBeforeExpiryDays) + daysLeft := int(expirationTime.Hours() / 24) + // TODO: 优化此处逻辑,[checkCanSkip] 方法不应该修改中间结果,违背单一职责 + n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(true) + n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(daysLeft), 10) + + return true, fmt.Sprintf("the certificate has already been issued (expires in %d day(s), next renewal in %d day(s))", daysLeft, currentNodeConfig.SkipBeforeExpiryDays) } } } diff --git a/internal/workflow/node-processor/condition_node.go b/internal/workflow/node-processor/condition_node.go index d90811d9..d9e8126d 100644 --- a/internal/workflow/node-processor/condition_node.go +++ b/internal/workflow/node-processor/condition_node.go @@ -3,8 +3,10 @@ package nodeprocessor import ( "context" "errors" + "fmt" "github.com/usual2970/certimate/internal/domain" + "github.com/usual2970/certimate/internal/domain/expr" ) type conditionNode struct { @@ -22,30 +24,29 @@ func NewConditionNode(node *domain.WorkflowNode) *conditionNode { } func (n *conditionNode) Process(ctx context.Context) error { - n.logger.Info("enter condition node: " + n.node.Name) - - nodeConfig := n.node.GetConfigForCondition() - if nodeConfig.Expression == nil { - n.logger.Info("no condition found, continue to next node") + nodeCfg := n.node.GetConfigForCondition() + if nodeCfg.Expression == nil { + n.logger.Info("without any conditions, enter this branch") return nil } - rs, err := n.eval(ctx, nodeConfig.Expression) + rs, err := n.evalExpr(ctx, nodeCfg.Expression) if err != nil { - n.logger.Warn("failed to eval expression: " + err.Error()) + n.logger.Warn(fmt.Sprintf("failed to eval condition expression: %w", err)) return err } if rs.Value == false { n.logger.Info("condition not met, skip this branch") - return errors.New("condition not met") + return errors.New("condition not met") // TODO: 错误处理 + } else { + n.logger.Info("condition met, enter this branch") } - n.logger.Info("condition met, continue to next node") return nil } -func (n *conditionNode) eval(ctx context.Context, expression domain.Expr) (*domain.EvalResult, error) { +func (n *conditionNode) evalExpr(ctx context.Context, expression expr.Expr) (*expr.EvalResult, error) { variables := GetNodeOutputs(ctx) return expression.Eval(variables) } diff --git a/internal/workflow/node-processor/const.go b/internal/workflow/node-processor/const.go index c1af01c9..62d2d56b 100644 --- a/internal/workflow/node-processor/const.go +++ b/internal/workflow/node-processor/const.go @@ -1,6 +1,6 @@ package nodeprocessor const ( - outputCertificateValidatedKey = "certificate.validated" - outputCertificateDaysLeftKey = "certificate.daysLeft" + outputKeyForCertificateValidity = "certificate.validity" + outputKeyForCertificateDaysLeft = "certificate.daysLeft" ) diff --git a/internal/workflow/node-processor/context.go b/internal/workflow/node-processor/context.go index adceacf6..96c40487 100644 --- a/internal/workflow/node-processor/context.go +++ b/internal/workflow/node-processor/context.go @@ -35,7 +35,8 @@ func AddNodeOutput(ctx context.Context, nodeId string, output map[string]any) co container.Lock() defer container.Unlock() - // 创建输出的深拷贝以避免后续修改 + // 创建输出的深拷贝 + // TODO: 暂时使用浅拷贝,等后续值类型扩充后修改 outputCopy := make(map[string]any, len(output)) for k, v := range output { outputCopy[k] = v @@ -90,6 +91,7 @@ func GetNodeOutputs(ctx context.Context) map[string]map[string]any { defer container.RUnlock() // 创建所有输出的深拷贝 + // TODO: 暂时使用浅拷贝,等后续值类型扩充后修改 allOutputs := make(map[string]map[string]any, len(container.outputs)) for nodeId, output := range container.outputs { nodeCopy := make(map[string]any, len(output)) diff --git a/internal/workflow/node-processor/deploy_node.go b/internal/workflow/node-processor/deploy_node.go index f0ded21d..f89f4a1f 100644 --- a/internal/workflow/node-processor/deploy_node.go +++ b/internal/workflow/node-processor/deploy_node.go @@ -42,8 +42,9 @@ func (n *deployNode) Process(ctx context.Context) error { } // 获取前序节点输出证书 + const DELIMITER = "#" previousNodeOutputCertificateSource := n.node.GetConfigForDeploy().Certificate - previousNodeOutputCertificateSourceSlice := strings.Split(previousNodeOutputCertificateSource, "#") + previousNodeOutputCertificateSourceSlice := strings.Split(previousNodeOutputCertificateSource, DELIMITER) if len(previousNodeOutputCertificateSourceSlice) != 2 { n.logger.Warn("invalid certificate source", slog.String("certificate.source", previousNodeOutputCertificateSource)) return fmt.Errorf("invalid certificate source: %s", previousNodeOutputCertificateSource) @@ -99,7 +100,7 @@ func (n *deployNode) Process(ctx context.Context) error { return nil } -func (n *deployNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (skip bool, reason string) { +func (n *deployNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (_skip bool, _reason string) { if lastOutput != nil && lastOutput.Succeeded { // 比较和上次部署时的关键配置(即影响证书部署的)参数是否一致 currentNodeConfig := n.node.GetConfigForDeploy() diff --git a/internal/workflow/node-processor/monitor_node.go b/internal/workflow/node-processor/monitor_node.go index f8c1adae..4b875f26 100644 --- a/internal/workflow/node-processor/monitor_node.go +++ b/internal/workflow/node-processor/monitor_node.go @@ -6,13 +6,13 @@ import ( "crypto/x509" "fmt" "math" - "net" "net/http" "strconv" "strings" "time" "github.com/usual2970/certimate/internal/domain" + httputil "github.com/usual2970/certimate/internal/pkg/utils/http" ) type monitorNode struct { @@ -32,23 +32,23 @@ func NewMonitorNode(node *domain.WorkflowNode) *monitorNode { func (n *monitorNode) Process(ctx context.Context) error { n.logger.Info("ready to monitor certificate ...") - nodeConfig := n.node.GetConfigForMonitor() + nodeCfg := n.node.GetConfigForMonitor() - targetAddr := fmt.Sprintf("%s:%d", nodeConfig.Host, nodeConfig.Port) - if nodeConfig.Port == 0 { - targetAddr = fmt.Sprintf("%s:443", nodeConfig.Host) + targetAddr := fmt.Sprintf("%s:%d", nodeCfg.Host, nodeCfg.Port) + if nodeCfg.Port == 0 { + targetAddr = fmt.Sprintf("%s:443", nodeCfg.Host) } - targetDomain := nodeConfig.Domain + targetDomain := nodeCfg.Domain if targetDomain == "" { - targetDomain = nodeConfig.Host + targetDomain = nodeCfg.Host } n.logger.Info(fmt.Sprintf("retrieving certificate at %s (domain: %s)", targetAddr, targetDomain)) const MAX_ATTEMPTS = 3 const RETRY_INTERVAL = 2 * time.Second - var cert *x509.Certificate + var certs []*x509.Certificate var err error for attempt := 0; attempt < MAX_ATTEMPTS; attempt++ { if attempt > 0 { @@ -61,7 +61,7 @@ func (n *monitorNode) Process(ctx context.Context) error { } } - cert, err = n.tryRetrieveCert(ctx, targetAddr, targetDomain, nodeConfig.RequestPath) + certs, err = n.tryRetrievePeerCertificates(ctx, targetAddr, targetDomain, nodeCfg.RequestPath) if err == nil { break } @@ -71,15 +71,13 @@ func (n *monitorNode) Process(ctx context.Context) error { n.logger.Warn("failed to monitor certificate") return err } else { - if cert == nil { + if len(certs) == 0 { n.logger.Warn("no ssl certificates retrieved in http response") - outputs := map[string]any{ - outputCertificateValidatedKey: strconv.FormatBool(false), - outputCertificateDaysLeftKey: strconv.FormatInt(0, 10), - } - n.setOutputs(outputs) + n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(false) + n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(0, 10) } else { + cert := certs[0] // 只取证书链中的第一个证书,即服务器证书 n.logger.Info(fmt.Sprintf("ssl certificate retrieved (serial='%s', subject='%s', issuer='%s', not_before='%s', not_after='%s', sans='%s')", cert.SerialNumber, cert.Subject.String(), cert.Issuer.String(), cert.NotBefore.Format(time.RFC3339), cert.NotAfter.Format(time.RFC3339), @@ -95,11 +93,8 @@ func (n *monitorNode) Process(ctx context.Context) error { validated := isCertPeriodValid && isCertHostMatched daysLeft := int(math.Floor(cert.NotAfter.Sub(now).Hours() / 24)) - outputs := map[string]any{ - outputCertificateValidatedKey: strconv.FormatBool(validated), - outputCertificateDaysLeftKey: strconv.FormatInt(int64(daysLeft), 10), - } - n.setOutputs(outputs) + n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(validated) + n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(daysLeft), 10) if validated { n.logger.Info(fmt.Sprintf("the certificate is valid, and will expire in %d day(s)", daysLeft)) @@ -113,52 +108,40 @@ func (n *monitorNode) Process(ctx context.Context) error { return nil } -func (n *monitorNode) tryRetrieveCert(ctx context.Context, addr, domain, requestPath string) (_cert *x509.Certificate, _err error) { - transport := &http.Transport{ - DialContext: (&net.Dialer{ - Timeout: 10 * time.Second, - }).DialContext, - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, - ForceAttemptHTTP2: false, - DisableKeepAlives: true, - Proxy: http.ProxyFromEnvironment, +func (n *monitorNode) tryRetrievePeerCertificates(ctx context.Context, addr, domain, requestPath string) ([]*x509.Certificate, error) { + transport := httputil.NewDefaultTransport() + if transport.TLSClientConfig == nil { + transport.TLSClientConfig = &tls.Config{} } + transport.TLSClientConfig.InsecureSkipVerify = true client := &http.Client{ - Transport: transport, - Timeout: 15 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, + Timeout: 30 * time.Second, + Transport: transport, } url := fmt.Sprintf("https://%s/%s", addr, strings.TrimLeft(requestPath, "/")) req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) if err != nil { - _err = fmt.Errorf("failed to create http request: %w", err) - n.logger.Warn(fmt.Sprintf("failed to create http request: %w", err)) - return nil, _err + err = fmt.Errorf("failed to create http request: %w", err) + n.logger.Warn(err.Error()) + return nil, err } req.Header.Set("User-Agent", "certimate") resp, err := client.Do(req) if err != nil { - _err = fmt.Errorf("failed to send http request: %w", err) - n.logger.Warn(fmt.Sprintf("failed to send http request: %w", err)) - return nil, _err + err = fmt.Errorf("failed to send http request: %w", err) + n.logger.Warn(err.Error()) + return nil, err } defer resp.Body.Close() if resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 { - return nil, _err + return make([]*x509.Certificate, 0), nil } - - _cert = resp.TLS.PeerCertificates[0] - return _cert, nil -} - -func (n *monitorNode) setOutputs(outputs map[string]any) { - n.outputs = outputs + return resp.TLS.PeerCertificates, nil } diff --git a/internal/workflow/node-processor/notify_node.go b/internal/workflow/node-processor/notify_node.go index f084cb4f..dabfd034 100644 --- a/internal/workflow/node-processor/notify_node.go +++ b/internal/workflow/node-processor/notify_node.go @@ -30,9 +30,9 @@ func NewNotifyNode(node *domain.WorkflowNode) *notifyNode { func (n *notifyNode) Process(ctx context.Context) error { n.logger.Info("ready to send notification ...") - nodeConfig := n.node.GetConfigForNotify() + nodeCfg := n.node.GetConfigForNotify() - if nodeConfig.Provider == "" { + if nodeCfg.Provider == "" { // Deprecated: v0.4.x 将废弃 // 兼容旧版本的通知渠道 n.logger.Warn("WARNING! you are using the notification channel from global settings, which will be deprecated in the future") @@ -44,14 +44,14 @@ func (n *notifyNode) Process(ctx context.Context) error { } // 获取通知渠道 - channelConfig, err := settings.GetNotifyChannelConfig(nodeConfig.Channel) + channelConfig, err := settings.GetNotifyChannelConfig(nodeCfg.Channel) if err != nil { return err } // 发送通知 - if err := notify.SendToChannel(nodeConfig.Subject, nodeConfig.Message, nodeConfig.Channel, channelConfig); err != nil { - n.logger.Warn("failed to send notification", slog.String("channel", nodeConfig.Channel)) + if err := notify.SendToChannel(nodeCfg.Subject, nodeCfg.Message, nodeCfg.Channel, channelConfig); err != nil { + n.logger.Warn("failed to send notification", slog.String("channel", nodeCfg.Channel)) return err } @@ -63,8 +63,8 @@ func (n *notifyNode) Process(ctx context.Context) error { deployer, err := notify.NewWithWorkflowNode(notify.NotifierWithWorkflowNodeConfig{ Node: n.node, Logger: n.logger, - Subject: nodeConfig.Subject, - Message: nodeConfig.Message, + Subject: nodeCfg.Subject, + Message: nodeCfg.Message, }) if err != nil { n.logger.Warn("failed to create notifier provider") diff --git a/internal/workflow/node-processor/upload_node.go b/internal/workflow/node-processor/upload_node.go index 8e59b009..9431d31a 100644 --- a/internal/workflow/node-processor/upload_node.go +++ b/internal/workflow/node-processor/upload_node.go @@ -3,6 +3,7 @@ package nodeprocessor import ( "context" "fmt" + "strconv" "strings" "time" @@ -33,7 +34,7 @@ func NewUploadNode(node *domain.WorkflowNode) *uploadNode { func (n *uploadNode) Process(ctx context.Context) error { n.logger.Info("ready to upload certiticate ...") - nodeConfig := n.node.GetConfigForUpload() + nodeCfg := n.node.GetConfigForUpload() // 查询上次执行结果 lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id) @@ -53,7 +54,7 @@ func (n *uploadNode) Process(ctx context.Context) error { certificate := &domain.Certificate{ Source: domain.CertificateSourceTypeUpload, } - certificate.PopulateFromPEM(nodeConfig.Certificate, nodeConfig.PrivateKey) + certificate.PopulateFromPEM(nodeCfg.Certificate, nodeCfg.PrivateKey) // 保存执行结果 output := &domain.WorkflowOutput{ @@ -69,15 +70,15 @@ func (n *uploadNode) Process(ctx context.Context) error { return err } - n.outputs[outputCertificateValidatedKey] = "true" - n.outputs[outputCertificateDaysLeftKey] = fmt.Sprintf("%d", int(time.Until(certificate.ExpireAt).Hours()/24)) + // 记录中间结果 + n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(true) + n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(time.Until(certificate.ExpireAt).Hours()/24), 10) n.logger.Info("uploading completed") - return nil } -func (n *uploadNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (skip bool, reason string) { +func (n *uploadNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (_skip bool, _reason string) { if lastOutput != nil && lastOutput.Succeeded { // 比较和上次上传时的关键配置(即影响证书上传的)参数是否一致 currentNodeConfig := n.node.GetConfigForUpload() @@ -91,8 +92,10 @@ func (n *uploadNode) checkCanSkip(ctx context.Context, lastOutput *domain.Workfl lastCertificate, _ := n.certRepo.GetByWorkflowRunId(ctx, lastOutput.RunId) if lastCertificate != nil { - n.outputs[outputCertificateValidatedKey] = "true" - n.outputs[outputCertificateDaysLeftKey] = fmt.Sprintf("%d", int(time.Until(lastCertificate.ExpireAt).Hours()/24)) + daysLeft := int(time.Until(lastCertificate.ExpireAt).Hours() / 24) + n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(daysLeft > 0) + n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(daysLeft), 10) + return true, "the certificate has already been uploaded" } } diff --git a/main.go b/main.go index 76f7f1c0..18e88bed 100644 --- a/main.go +++ b/main.go @@ -26,9 +26,7 @@ func main() { app := app.GetApp().(*pocketbase.PocketBase) var flagHttp string - var flagDir string flag.StringVar(&flagHttp, "http", "127.0.0.1:8090", "HTTP server address") - flag.StringVar(&flagDir, "dir", "/pb_data/database", "Pocketbase data directory") if len(os.Args) < 2 { slog.Error("[CERTIMATE] missing exec args") os.Exit(1) @@ -59,14 +57,17 @@ func main() { Priority: 999, }) + app.OnServe().BindFunc(func(e *core.ServeEvent) error { + slog.Info("[CERTIMATE] Visit the website: http://" + flagHttp) + return e.Next() + }) + app.OnTerminate().BindFunc(func(e *core.TerminateEvent) error { routes.Unregister() slog.Info("[CERTIMATE] Exit!") return e.Next() }) - slog.Info("[CERTIMATE] Visit the website: http://" + flagHttp) - if err := app.Start(); err != nil { slog.Error("[CERTIMATE] Start failed.", "err", err) } diff --git a/ui/src/components/MultipleInput.tsx b/ui/src/components/MultipleInput.tsx index d28db745..d8be12fa 100644 --- a/ui/src/components/MultipleInput.tsx +++ b/ui/src/components/MultipleInput.tsx @@ -152,10 +152,10 @@ const MultipleInput = ({ value={element} onBlur={() => handleInputBlur(index)} onChange={(val) => handleChange(index, val)} - onClickAdd={() => handleClickAdd(index)} - onClickDown={() => handleClickDown(index)} - onClickUp={() => handleClickUp(index)} - onClickRemove={() => handleClickRemove(index)} + onEntryAdd={() => handleClickAdd(index)} + onEntryDown={() => handleClickDown(index)} + onEntryUp={() => handleClickUp(index)} + onEntryRemove={() => handleClickRemove(index)} /> ); })} @@ -174,10 +174,10 @@ type MultipleInputItemProps = Omit< defaultValue?: string; value?: string; onChange?: (value: string) => void; - onClickAdd?: () => void; - onClickDown?: () => void; - onClickUp?: () => void; - onClickRemove?: () => void; + onEntryAdd?: () => void; + onEntryDown?: () => void; + onEntryUp?: () => void; + onEntryRemove?: () => void; }; type MultipleInputItemInstance = { @@ -197,10 +197,10 @@ const MultipleInputItem = forwardRef { if (!showSortButton) return null; - return diff --git a/ui/src/components/access/AccessSelect.tsx b/ui/src/components/access/AccessSelect.tsx index 0a570699..01f30249 100644 --- a/ui/src/components/access/AccessSelect.tsx +++ b/ui/src/components/access/AccessSelect.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Avatar, Select, type SelectProps, Space, Typography } from "antd"; +import { Avatar, Select, type SelectProps, Space, Typography, theme } from "antd"; import { type AccessModel } from "@/domain/access"; import { accessProvidersMap } from "@/domain/provider"; @@ -14,6 +14,8 @@ export type AccessTypeSelectProps = Omit< }; const AccessSelect = ({ filter, ...props }: AccessTypeSelectProps) => { + const { token: themeToken } = theme.useToken(); + const { accesses, loadedAtOnce, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "loadedAtOnce", "fetchAccesses"])); useEffect(() => { fetchAccesses(); @@ -65,12 +67,12 @@ const AccessSelect = ({ filter, ...props }: AccessTypeSelectProps) => { const value = inputValue.toLowerCase(); return option.label.toLowerCase().includes(value); }} - labelRender={({ label, value }) => { - if (label) { + labelRender={({ value }) => { + if (value != null) { return renderOption(value as string); } - return {props.placeholder}; + return {props.placeholder}; }} loading={!loadedAtOnce} options={options} diff --git a/ui/src/components/provider/ACMEDns01ProviderSelect.tsx b/ui/src/components/provider/ACMEDns01ProviderSelect.tsx index e2408eeb..227bfcdd 100644 --- a/ui/src/components/provider/ACMEDns01ProviderSelect.tsx +++ b/ui/src/components/provider/ACMEDns01ProviderSelect.tsx @@ -1,6 +1,6 @@ import { memo, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Avatar, Select, type SelectProps, Space, Typography } from "antd"; +import { Avatar, Select, type SelectProps, Space, Typography, theme } from "antd"; import { type ACMEDns01Provider, acmeDns01ProvidersMap } from "@/domain/provider"; @@ -14,6 +14,8 @@ export type ACMEDns01ProviderSelectProps = Omit< const ACMEDns01ProviderSelect = ({ filter, ...props }: ACMEDns01ProviderSelectProps) => { const { t } = useTranslation(); + const { token: themeToken } = theme.useToken(); + const [options, setOptions] = useState>([]); useEffect(() => { const allItems = Array.from(acmeDns01ProvidersMap.values()); @@ -49,12 +51,12 @@ const ACMEDns01ProviderSelect = ({ filter, ...props }: ACMEDns01ProviderSelectPr const value = inputValue.toLowerCase(); return option.value.toLowerCase().includes(value) || option.label.toLowerCase().includes(value); }} - labelRender={({ label, value }) => { - if (!label) { - return {props.placeholder}; + labelRender={({ value }) => { + if (value != null) { + return renderOption(value as string); } - return renderOption(value as string); + return {props.placeholder}; }} options={options} optionFilterProp={undefined} diff --git a/ui/src/components/provider/AccessProviderSelect.tsx b/ui/src/components/provider/AccessProviderSelect.tsx index bf4ff6e7..055b3ddc 100644 --- a/ui/src/components/provider/AccessProviderSelect.tsx +++ b/ui/src/components/provider/AccessProviderSelect.tsx @@ -1,6 +1,6 @@ import { memo, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Avatar, Select, type SelectProps, Space, Tag, Typography } from "antd"; +import { Avatar, Select, type SelectProps, Space, Tag, Typography, theme } from "antd"; import Show from "@/components/Show"; import { ACCESS_USAGES, type AccessProvider, type AccessUsageType, accessProvidersMap } from "@/domain/provider"; @@ -16,6 +16,8 @@ export type AccessProviderSelectProps = Omit< const AccessProviderSelect = ({ filter, showOptionTags, ...props }: AccessProviderSelectProps = { showOptionTags: true }) => { const { t } = useTranslation(); + const { token: themeToken } = theme.useToken(); + const [options, setOptions] = useState>([]); useEffect(() => { const allItems = Array.from(accessProvidersMap.values()); @@ -84,12 +86,12 @@ const AccessProviderSelect = ({ filter, showOptionTags, ...props }: AccessProvid const value = inputValue.toLowerCase(); return option.value.toLowerCase().includes(value) || option.label.toLowerCase().includes(value); }} - labelRender={({ label, value }) => { - if (!label) { - return {props.placeholder}; + labelRender={({ value }) => { + if (value != null) { + return renderOption(value as string); } - return renderOption(value as string); + return {props.placeholder}; }} options={options} optionFilterProp={undefined} diff --git a/ui/src/components/provider/CAProviderSelect.tsx b/ui/src/components/provider/CAProviderSelect.tsx index e5477c21..d1fdbba9 100644 --- a/ui/src/components/provider/CAProviderSelect.tsx +++ b/ui/src/components/provider/CAProviderSelect.tsx @@ -1,6 +1,6 @@ import { memo, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Avatar, Select, type SelectProps, Space, Typography } from "antd"; +import { Avatar, Select, type SelectProps, Space, Typography, theme } from "antd"; import { type CAProvider, caProvidersMap } from "@/domain/provider"; @@ -14,6 +14,8 @@ export type CAProviderSelectProps = Omit< const CAProviderSelect = ({ filter, ...props }: CAProviderSelectProps) => { const { t } = useTranslation(); + const { token: themeToken } = theme.useToken(); + const [options, setOptions] = useState>([]); useEffect(() => { const allItems = Array.from(caProvidersMap.values()); @@ -65,12 +67,12 @@ const CAProviderSelect = ({ filter, ...props }: CAProviderSelectProps) => { const value = inputValue.toLowerCase(); return option.value.toLowerCase().includes(value) || option.label.toLowerCase().includes(value); }} - labelRender={({ label, value }) => { - if (!label) { - return {props.placeholder || t("provider.default_ca_provider.label")}; + labelRender={({ value }) => { + if (value != null) { + return renderOption(value as string); } - return renderOption(value as string); + return {props.placeholder}; }} options={options} optionFilterProp={undefined} diff --git a/ui/src/components/provider/DeploymentProviderSelect.tsx b/ui/src/components/provider/DeploymentProviderSelect.tsx index 89173243..07fa4577 100644 --- a/ui/src/components/provider/DeploymentProviderSelect.tsx +++ b/ui/src/components/provider/DeploymentProviderSelect.tsx @@ -1,6 +1,6 @@ import { memo, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Avatar, Select, type SelectProps, Space, Typography } from "antd"; +import { Avatar, Select, type SelectProps, Space, Typography, theme } from "antd"; import { type DeploymentProvider, deploymentProvidersMap } from "@/domain/provider"; @@ -14,6 +14,8 @@ export type DeploymentProviderSelectProps = Omit< const DeploymentProviderSelect = ({ filter, ...props }: DeploymentProviderSelectProps) => { const { t } = useTranslation(); + const { token: themeToken } = theme.useToken(); + const [options, setOptions] = useState>([]); useEffect(() => { const allItems = Array.from(deploymentProvidersMap.values()); @@ -49,12 +51,12 @@ const DeploymentProviderSelect = ({ filter, ...props }: DeploymentProviderSelect const value = inputValue.toLowerCase(); return option.value.toLowerCase().includes(value) || option.label.toLowerCase().includes(value); }} - labelRender={({ label, value }) => { - if (!label) { - return {props.placeholder}; + labelRender={({ value }) => { + if (value != null) { + return renderOption(value as string); } - return renderOption(value as string); + return {props.placeholder}; }} options={options} optionFilterProp={undefined} diff --git a/ui/src/components/provider/NotificationProviderSelect.tsx b/ui/src/components/provider/NotificationProviderSelect.tsx index f30a8f6f..8b0dd353 100644 --- a/ui/src/components/provider/NotificationProviderSelect.tsx +++ b/ui/src/components/provider/NotificationProviderSelect.tsx @@ -1,6 +1,6 @@ import { memo, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Avatar, Select, type SelectProps, Space, Typography } from "antd"; +import { Avatar, Select, type SelectProps, Space, Typography, theme } from "antd"; import { type NotificationProvider, notificationProvidersMap } from "@/domain/provider"; @@ -14,6 +14,8 @@ export type NotificationProviderSelectProps = Omit< const NotificationProviderSelect = ({ filter, ...props }: NotificationProviderSelectProps) => { const { t } = useTranslation(); + const { token: themeToken } = theme.useToken(); + const [options, setOptions] = useState>([]); useEffect(() => { const allItems = Array.from(notificationProvidersMap.values()); @@ -49,12 +51,12 @@ const NotificationProviderSelect = ({ filter, ...props }: NotificationProviderSe const value = inputValue.toLowerCase(); return option.value.toLowerCase().includes(value) || option.label.toLowerCase().includes(value); }} - labelRender={({ label, value }) => { - if (!label) { - return {props.placeholder}; + labelRender={({ value }) => { + if (value != null) { + return renderOption(value as string); } - return renderOption(value as string); + return {props.placeholder}; }} options={options} optionFilterProp={undefined} diff --git a/ui/src/components/workflow/WorkflowRunDetail.tsx b/ui/src/components/workflow/WorkflowRunDetail.tsx index 2d421880..746adb4c 100644 --- a/ui/src/components/workflow/WorkflowRunDetail.tsx +++ b/ui/src/components/workflow/WorkflowRunDetail.tsx @@ -36,7 +36,7 @@ import { ClientResponseError } from "pocketbase"; import CertificateDetailDrawer from "@/components/certificate/CertificateDetailDrawer"; import Show from "@/components/Show"; import { type CertificateModel } from "@/domain/certificate"; -import type { WorkflowLogModel } from "@/domain/workflowLog"; +import { type WorkflowLogModel } from "@/domain/workflowLog"; import { WORKFLOW_RUN_STATUSES, type WorkflowRunModel } from "@/domain/workflowRun"; import { useBrowserTheme } from "@/hooks"; import { listByWorkflowRunId as listCertificatesByWorkflowRunId } from "@/repository/certificate"; diff --git a/ui/src/components/workflow/node/AddNode.tsx b/ui/src/components/workflow/node/AddNode.tsx index 86a45134..207ec7c7 100644 --- a/ui/src/components/workflow/node/AddNode.tsx +++ b/ui/src/components/workflow/node/AddNode.tsx @@ -35,7 +35,14 @@ const AddNode = ({ node, disabled }: AddNodeProps) => { [WorkflowNodeType.ExecuteResultBranch, "workflow_node.execute_result_branch.label", ], ] .filter(([type]) => { - if (node.type !== WorkflowNodeType.Apply && node.type !== WorkflowNodeType.Deploy && node.type !== WorkflowNodeType.Notify) { + const hasExecuteResult = [ + WorkflowNodeType.Apply, + WorkflowNodeType.Upload, + WorkflowNodeType.Monitor, + WorkflowNodeType.Deploy, + WorkflowNodeType.Notify, + ].includes(node.type); + if (!hasExecuteResult) { return type !== WorkflowNodeType.ExecuteResultBranch; } diff --git a/ui/src/components/workflow/node/ApplyNode.tsx b/ui/src/components/workflow/node/ApplyNode.tsx index c250fd89..ff0d64bf 100644 --- a/ui/src/components/workflow/node/ApplyNode.tsx +++ b/ui/src/components/workflow/node/ApplyNode.tsx @@ -38,9 +38,9 @@ const ApplyNode = ({ node, disabled }: ApplyNodeProps) => { const formRef = useRef(null); const [formPending, setFormPending] = useState(false); + const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForApply; const [drawerOpen, setDrawerOpen] = useState(false); - const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForApply; const handleDrawerConfirm = async () => { setFormPending(true); @@ -74,12 +74,12 @@ const ApplyNode = ({ node, disabled }: ApplyNodeProps) => { setDrawerOpen(open)} - getFormValues={() => formRef.current!.getFieldsValue()} > diff --git a/ui/src/components/workflow/node/ApplyNodeConfigForm.tsx b/ui/src/components/workflow/node/ApplyNodeConfigForm.tsx index 7faa148e..ae56efc3 100644 --- a/ui/src/components/workflow/node/ApplyNodeConfigForm.tsx +++ b/ui/src/components/workflow/node/ApplyNodeConfigForm.tsx @@ -56,7 +56,7 @@ export type ApplyNodeConfigFormInstance = { validateFields: FormInstance["validateFields"]; }; -const MULTIPLE_INPUT_DELIMITER = ";"; +const MULTIPLE_INPUT_SEPARATOR = ";"; const initFormModel = (): ApplyNodeConfigFormFieldValues => { return { @@ -76,7 +76,7 @@ const ApplyNodeConfigForm = forwardRef { if (!v) return false; return String(v) - .split(MULTIPLE_INPUT_DELIMITER) + .split(MULTIPLE_INPUT_SEPARATOR) .every((e) => validDomainName(e, { allowWildcard: true })); }, t("common.errmsg.domain_invalid")), contactEmail: z.string({ message: t("workflow_node.apply.form.contact_email.placeholder") }).email(t("common.errmsg.email_invalid")), @@ -106,7 +106,7 @@ const ApplyNodeConfigForm = forwardRef { if (!v) return true; return String(v) - .split(MULTIPLE_INPUT_DELIMITER) + .split(MULTIPLE_INPUT_SEPARATOR) .every((e) => validIPv4Address(e) || validIPv6Address(e) || validDomainName(e)); }, t("common.errmsg.host_invalid")), dnsPropagationWait: z.preprocess( diff --git a/ui/src/components/workflow/node/ConditionNode.tsx b/ui/src/components/workflow/node/ConditionNode.tsx index bc5b5918..2c8b3d81 100644 --- a/ui/src/components/workflow/node/ConditionNode.tsx +++ b/ui/src/components/workflow/node/ConditionNode.tsx @@ -1,17 +1,14 @@ import { memo, useRef, useState } from "react"; -import { MoreOutlined as MoreOutlinedIcon } from "@ant-design/icons"; +import { FilterFilled as FilterFilledIcon, FilterOutlined as FilterOutlinedIcon, MoreOutlined as MoreOutlinedIcon } from "@ant-design/icons"; import { Button, Card, Popover } from "antd"; import { produce } from "immer"; -import type { Expr, WorkflowNodeIoValueType } from "@/domain/workflow"; -import { ExprType } from "@/domain/workflow"; import { useZustandShallowSelector } from "@/hooks"; import { useWorkflowStore } from "@/stores/workflow"; import SharedNode, { type SharedNodeProps } from "./_SharedNode"; import AddNode from "./AddNode"; -import type { ConditionItem, ConditionNodeConfigFormFieldValues, ConditionNodeConfigFormInstance } from "./ConditionNodeConfigForm"; -import ConditionNodeConfigForm from "./ConditionNodeConfigForm"; +import ConditionNodeConfigForm, { type ConditionNodeConfigFormFieldValues, type ConditionNodeConfigFormInstance } from "./ConditionNodeConfigForm"; export type ConditionNodeProps = SharedNodeProps & { branchId: string; @@ -23,55 +20,9 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP const [formPending, setFormPending] = useState(false); const formRef = useRef(null); - - const [drawerOpen, setDrawerOpen] = useState(false); - const getFormValues = () => formRef.current!.getFieldsValue() as ConditionNodeConfigFormFieldValues; - // 将表单值转换为表达式结构 - const formToExpression = (values: ConditionNodeConfigFormFieldValues): Expr => { - // 创建单个条件的表达式 - const createComparisonExpr = (condition: ConditionItem): Expr => { - const selectors = condition.leftSelector.split("#"); - const t = selectors[2] as WorkflowNodeIoValueType; - const left: Expr = { - type: ExprType.Var, - selector: { - id: selectors[0], - name: selectors[1], - type: t, - }, - }; - - const right: Expr = { type: ExprType.Const, value: condition.rightValue, valueType: t }; - - return { - type: ExprType.Compare, - op: condition.operator, - left, - right, - }; - }; - - // 如果只有一个条件,直接返回比较表达式 - if (values.conditions.length === 1) { - return createComparisonExpr(values.conditions[0]); - } - - // 多个条件,通过逻辑运算符连接 - let expr: Expr = createComparisonExpr(values.conditions[0]); - - for (let i = 1; i < values.conditions.length; i++) { - expr = { - type: ExprType.Logical, - op: values.logicalOperator, - left: expr, - right: createComparisonExpr(values.conditions[i]), - }; - } - - return expr; - }; + const [drawerOpen, setDrawerOpen] = useState(false); const handleDrawerConfirm = async () => { setFormPending(true); @@ -84,10 +35,9 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP try { const newValues = getFormValues(); - const expression = formToExpression(newValues); const newNode = produce(node, (draft) => { draft.config = { - expression, + ...newValues, }; draft.validated = true; }); @@ -100,7 +50,7 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP return ( <> setDrawerOpen(true)}>
- +
e.stopPropagation()}> + +
setDrawerOpen(true)}> + {node.config?.expression ? ( +
+
- - setDrawerOpen(open)} - getFormValues={() => formRef.current!.getFieldsValue()} - > - -
+ setDrawerOpen(open)} + > + + + ); diff --git a/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx b/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx index 9cbb56cc..3cd92d7b 100644 --- a/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx +++ b/ui/src/components/workflow/node/ConditionNodeConfigForm.tsx @@ -1,36 +1,16 @@ -import { forwardRef, memo, useEffect, useImperativeHandle, useState } from "react"; -import { Button, Card, Form, Input, Select, Radio } from "antd"; -import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"; -import i18n from "@/i18n"; - -import { - WorkflowNodeConfigForCondition, - Expr, - WorkflowNodeIOValueSelector, - ComparisonOperator, - LogicalOperator, - isConstExpr, - isVarExpr, - WorkflowNode, - workflowNodeIOOptions, - WorkflowNodeIoValueType, - ExprType, -} from "@/domain/workflow"; -import { FormInstance } from "antd"; -import { useZustandShallowSelector } from "@/hooks"; -import { useWorkflowStore } from "@/stores/workflow"; +import { forwardRef, memo, useImperativeHandle, useRef } from "react"; import { useTranslation } from "react-i18next"; +import { Form, type FormInstance } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import { z } from "zod"; -// 表单内部使用的扁平结构 - 修改后只保留必要字段 -export interface ConditionItem { - leftSelector: string; - operator: ComparisonOperator; - rightValue: string; -} +import { type Expr, type WorkflowNodeConfigForCondition } from "@/domain/workflow"; +import { useAntdForm } from "@/hooks"; + +import ConditionNodeConfigFormExpressionEditor, { type ConditionNodeConfigFormExpressionEditorInstance } from "./ConditionNodeConfigFormExpressionEditor"; export type ConditionNodeConfigFormFieldValues = { - conditions: ConditionItem[]; - logicalOperator: LogicalOperator; + expression?: Expr | undefined; }; export type ConditionNodeConfigFormProps = { @@ -38,9 +18,8 @@ export type ConditionNodeConfigFormProps = { style?: React.CSSProperties; disabled?: boolean; initialValues?: Partial; - onValuesChange?: (values: WorkflowNodeConfigForCondition) => void; - availableSelectors?: WorkflowNodeIOValueSelector[]; nodeId: string; + onValuesChange?: (values: WorkflowNodeConfigForCondition) => void; }; export type ConditionNodeConfigFormInstance = { @@ -49,298 +28,49 @@ export type ConditionNodeConfigFormInstance = { validateFields: FormInstance["validateFields"]; }; -// 初始表单值 const initFormModel = (): ConditionNodeConfigFormFieldValues => { - return { - conditions: [ - { - leftSelector: "", - operator: "==", - rightValue: "", - }, - ], - logicalOperator: LogicalOperator.And, - }; -}; - -// 递归提取表达式中的条件项 -const expressionToForm = (expr?: Expr): ConditionNodeConfigFormFieldValues => { - if (!expr) return initFormModel(); - - const conditions: ConditionItem[] = []; - let logicalOp: LogicalOperator = LogicalOperator.And; - - const extractComparisons = (expr: Expr): void => { - if (expr.type === ExprType.Compare) { - // 确保左侧是变量,右侧是常量 - if (isVarExpr(expr.left) && isConstExpr(expr.right)) { - conditions.push({ - leftSelector: `${expr.left.selector.id}#${expr.left.selector.name}#${expr.left.selector.type}`, - operator: expr.op, - rightValue: String(expr.right.value), - }); - } - } else if (expr.type === ExprType.Logical) { - logicalOp = expr.op; - extractComparisons(expr.left); - extractComparisons(expr.right); - } - }; - - extractComparisons(expr); - - return { - conditions: conditions.length > 0 ? conditions : initFormModel().conditions, - logicalOperator: logicalOp, - }; -}; - -// 根据变量类型获取适当的操作符选项 -const getOperatorsByType = (type: string): { value: ComparisonOperator; label: string }[] => { - switch (type) { - case "number": - case "string": - return [ - { value: "==", label: i18n.t("workflow_node.condition.form.comparison.equal") }, - { value: "!=", label: i18n.t("workflow_node.condition.form.comparison.not_equal") }, - { value: ">", label: i18n.t("workflow_node.condition.form.comparison.greater_than") }, - { value: ">=", label: i18n.t("workflow_node.condition.form.comparison.greater_than_or_equal") }, - { value: "<", label: i18n.t("workflow_node.condition.form.comparison.less_than") }, - { value: "<=", label: i18n.t("workflow_node.condition.form.comparison.less_than_or_equal") }, - ]; - case "boolean": - return [{ value: "is", label: i18n.t("workflow_node.condition.form.comparison.is") }]; - default: - return []; - } -}; - -// 从选择器字符串中提取变量类型 -const getVariableTypeFromSelector = (selector: string): string => { - if (!selector) return "string"; - - // 假设选择器格式为 "id#name#type" - const parts = selector.split("#"); - if (parts.length >= 3) { - return parts[2].toLowerCase() || "string"; - } - return "string"; + return {}; }; const ConditionNodeConfigForm = forwardRef( - ({ className, style, disabled, initialValues, onValuesChange, nodeId }, ref) => { + ({ className, style, disabled, initialValues, nodeId, onValuesChange }, ref) => { const { t } = useTranslation(); - const prefix = "workflow_node.condition.form"; - const { getWorkflowOuptutBeforeId } = useWorkflowStore(useZustandShallowSelector(["updateNode", "getWorkflowOuptutBeforeId"])); + const formSchema = z.object({ + expression: z.any().nullish(), + }); + const formRule = createSchemaFieldRule(formSchema); + const { form: formInst, formProps } = useAntdForm({ + name: "workflowNodeConditionConfigForm", + initialValues: initialValues ?? initFormModel(), + }); - const [form] = Form.useForm(); - const [formModel, setFormModel] = useState(initFormModel()); + const editorRef = useRef(null); - const [previousNodes, setPreviousNodes] = useState([]); - useEffect(() => { - const previousNodes = getWorkflowOuptutBeforeId(nodeId); - setPreviousNodes(previousNodes); - }, [nodeId]); - - // 初始化表单值 - useEffect(() => { - if (initialValues?.expression) { - const formValues = expressionToForm(initialValues.expression); - form.setFieldsValue(formValues); - setFormModel(formValues); - } - }, [form, initialValues]); - - // 公开表单方法 - useImperativeHandle( - ref, - () => ({ - getFieldsValue: form.getFieldsValue, - resetFields: form.resetFields, - validateFields: form.validateFields, - }), - [form] - ); - - // 表单值变更处理 - const handleFormChange = (_: undefined, values: ConditionNodeConfigFormFieldValues) => { - setFormModel(values); - - if (onValuesChange) { - // 将表单值转换为表达式 - const expression = formToExpression(values); - onValuesChange({ expression }); - } + const handleFormChange = (_: unknown, values: z.infer) => { + onValuesChange?.(values); }; + useImperativeHandle(ref, () => { + return { + getFieldsValue: formInst.getFieldsValue, + resetFields: formInst.resetFields, + validateFields: (nameList, config) => { + const t1 = formInst.validateFields(nameList, config); + const t2 = editorRef.current!.validate(); + return Promise.all([t1, t2]).then(() => t1); + }, + } as ConditionNodeConfigFormInstance; + }); + return ( -
- - {(fields, { add, remove }) => ( - <> - {fields.map(({ key, name, ...restField }) => ( - 1 ? - - - )} - - - {formModel.conditions && formModel.conditions.length > 1 && ( - - - {t(`${prefix}.logical_operator.and`)} - {t(`${prefix}.logical_operator.or`)} - - - )} + + + +
); } ); -// 表单值转换为表达式结构 (需要添加) -const formToExpression = (values: ConditionNodeConfigFormFieldValues): Expr => { - const createComparisonExpr = (condition: ConditionItem): Expr => { - const [id, name, typeStr] = condition.leftSelector.split("#"); - - const type = typeStr as WorkflowNodeIoValueType; - - const left: Expr = { - type: ExprType.Var, - selector: { id, name, type }, - }; - - const right: Expr = { - type: ExprType.Const, - value: condition.rightValue, - valueType: type, - }; - - return { - type: ExprType.Compare, - op: condition.operator, - left, - right, - }; - }; - - // 如果只有一个条件,直接返回比较表达式 - if (values.conditions.length === 1) { - return createComparisonExpr(values.conditions[0]); - } - - // 多个条件,通过逻辑运算符连接 - let expr: Expr = createComparisonExpr(values.conditions[0]); - - for (let i = 1; i < values.conditions.length; i++) { - expr = { - type: ExprType.Logical, - op: values.logicalOperator, - left: expr, - right: createComparisonExpr(values.conditions[i]), - }; - } - - return expr; -}; - export default memo(ConditionNodeConfigForm); diff --git a/ui/src/components/workflow/node/ConditionNodeConfigFormExpressionEditor.tsx b/ui/src/components/workflow/node/ConditionNodeConfigFormExpressionEditor.tsx new file mode 100644 index 00000000..1696d46c --- /dev/null +++ b/ui/src/components/workflow/node/ConditionNodeConfigFormExpressionEditor.tsx @@ -0,0 +1,400 @@ +import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { CloseOutlined as CloseOutlinedIcon, PlusOutlined } from "@ant-design/icons"; +import { useControllableValue } from "ahooks"; +import { Button, Form, Input, Radio, Select, theme } from "antd"; + +import Show from "@/components/Show"; +import type { Expr, ExprComparisonOperator, ExprLogicalOperator, ExprValue, ExprValueSelector, ExprValueType } from "@/domain/workflow"; +import { ExprType } from "@/domain/workflow"; +import { useAntdFormName, useZustandShallowSelector } from "@/hooks"; +import { useWorkflowStore } from "@/stores/workflow"; + +export type ConditionNodeConfigFormExpressionEditorProps = { + className?: string; + style?: React.CSSProperties; + defaultValue?: Expr; + disabled?: boolean; + nodeId: string; + value?: Expr; + onChange?: (value: Expr) => void; +}; + +export type ConditionNodeConfigFormExpressionEditorInstance = { + validate: () => Promise; +}; + +// 表单内部使用的扁平结构 +type ConditionItem = { + // 选择器,格式为 "${nodeId}#${outputName}#${valueType}" + // 将 [ExprValueSelector] 转为字符串形式,以便于结构化存储。 + leftSelector?: string; + // 比较运算符。 + operator?: ExprComparisonOperator; + // 值。 + // 将 [ExprValue] 转为字符串形式,以便于结构化存储。 + rightValue?: string; +}; + +type ConditionFormValues = { + conditions: ConditionItem[]; + logicalOperator: ExprLogicalOperator; +}; + +const initFormModel = (): ConditionFormValues => { + return { + conditions: [{}], + logicalOperator: "and", + }; +}; + +const exprToFormValues = (expr?: Expr): ConditionFormValues => { + if (!expr) return initFormModel(); + + const conditions: ConditionItem[] = []; + let logicalOp: ExprLogicalOperator = "and"; + + const extractExpr = (expr: Expr): void => { + if (expr.type === ExprType.Comparison) { + if (expr.left.type == ExprType.Variant && expr.right.type == ExprType.Constant) { + conditions.push({ + leftSelector: expr.left.selector?.id != null ? `${expr.left.selector.id}#${expr.left.selector.name}#${expr.left.selector.type}` : undefined, + operator: expr.operator != null ? expr.operator : undefined, + rightValue: expr.right?.value != null ? String(expr.right.value) : undefined, + }); + } else { + console.warn("[certimate] invalid comparison expression: left must be a variant and right must be a constant", expr); + } + } else if (expr.type === ExprType.Logical) { + logicalOp = expr.operator || "and"; + extractExpr(expr.left); + extractExpr(expr.right); + } + }; + + extractExpr(expr); + + return { + conditions: conditions, + logicalOperator: logicalOp, + }; +}; + +const formValuesToExpr = (values: ConditionFormValues): Expr | undefined => { + const wrapExpr = (condition: ConditionItem): Expr => { + const [id, name, type] = (condition.leftSelector?.split("#") ?? ["", "", ""]) as [string, string, ExprValueType]; + const valid = !!id && !!name && !!type; + + const left: Expr = { + type: ExprType.Variant, + selector: valid + ? { + id: id, + name: name, + type: type, + } + : ({} as ExprValueSelector), + }; + + const right: Expr = { + type: ExprType.Constant, + value: condition.rightValue!, + valueType: type, + }; + + return { + type: ExprType.Comparison, + operator: condition.operator!, + left, + right, + }; + }; + + if (values.conditions.length === 0) { + return undefined; + } + + // 只有一个条件时,直接返回比较表达式 + if (values.conditions.length === 1) { + const { leftSelector, operator, rightValue } = values.conditions[0]; + if (!leftSelector || !operator || !rightValue) { + return undefined; + } + return wrapExpr(values.conditions[0]); + } + + // 多个条件时,通过逻辑运算符连接 + let expr: Expr = wrapExpr(values.conditions[0]); + for (let i = 1; i < values.conditions.length; i++) { + expr = { + type: ExprType.Logical, + operator: values.logicalOperator, + left: expr, + right: wrapExpr(values.conditions[i]), + }; + } + return expr; +}; + +const ConditionNodeConfigFormExpressionEditor = forwardRef( + ({ className, style, disabled, nodeId, ...props }, ref) => { + const { t } = useTranslation(); + + const { token: themeToken } = theme.useToken(); + + const { getWorkflowOuptutBeforeId } = useWorkflowStore(useZustandShallowSelector(["updateNode", "getWorkflowOuptutBeforeId"])); + + const [value, setValue] = useControllableValue(props, { + valuePropName: "value", + defaultValuePropName: "defaultValue", + trigger: "onChange", + }); + + const [formInst] = Form.useForm(); + const formName = useAntdFormName({ form: formInst, name: "workflowNodeConditionConfigFormExpressionEditorForm" }); + const [formModel, setFormModel] = useState(initFormModel()); + + useEffect(() => { + if (value) { + const formValues = exprToFormValues(value); + formInst.setFieldsValue(formValues); + setFormModel(formValues); + } else { + formInst.resetFields(); + setFormModel(initFormModel()); + } + }, [value]); + + const ciSelectorCandidates = useMemo(() => { + const previousNodes = getWorkflowOuptutBeforeId(nodeId); + return previousNodes + .map((node) => { + const group = { + label: node.name, + options: Array<{ label: string; value: string }>(), + }; + + for (const output of node.outputs ?? []) { + switch (output.type) { + case "certificate": + group.options.push({ + label: `${output.label} - ${t("workflow.variables.selector.validity.label")}`, + value: `${node.id}#${output.name}.validity#boolean`, + }); + group.options.push({ + label: `${output.label} - ${t("workflow.variables.selector.days_left.label")}`, + value: `${node.id}#${output.name}.daysLeft#number`, + }); + break; + + default: + group.options.push({ + label: `${output.label}`, + value: `${node.id}#${output.name}#${output.type}`, + }); + console.warn("[certimate] invalid workflow output type in condition expressions", output); + break; + } + } + + return group; + }) + .filter((item) => item.options.length > 0); + }, [nodeId]); + + const getValueTypeBySelector = (selector: string): ExprValueType | undefined => { + if (!selector) return; + + const parts = selector.split("#"); + if (parts.length >= 3) { + return parts[2].toLowerCase() as ExprValueType; + } + }; + + const getOperatorsBySelector = (selector: string): { value: ExprComparisonOperator; label: string }[] => { + const valueType = getValueTypeBySelector(selector); + return getOperatorsByValueType(valueType!); + }; + + const getOperatorsByValueType = (valueType: ExprValue): { value: ExprComparisonOperator; label: string }[] => { + switch (valueType) { + case "number": + return [ + { value: "eq", label: t("workflow_node.condition.form.expression.operator.option.eq.label") }, + { value: "neq", label: t("workflow_node.condition.form.expression.operator.option.neq.label") }, + { value: "gt", label: t("workflow_node.condition.form.expression.operator.option.gt.label") }, + { value: "gte", label: t("workflow_node.condition.form.expression.operator.option.gte.label") }, + { value: "lt", label: t("workflow_node.condition.form.expression.operator.option.lt.label") }, + { value: "lte", label: t("workflow_node.condition.form.expression.operator.option.lte.label") }, + ]; + + case "string": + return [ + { value: "eq", label: t("workflow_node.condition.form.expression.operator.option.eq.label") }, + { value: "neq", label: t("workflow_node.condition.form.expression.operator.option.neq.label") }, + ]; + + case "boolean": + return [ + { value: "eq", label: t("workflow_node.condition.form.expression.operator.option.eq.alias_is_label") }, + { value: "neq", label: t("workflow_node.condition.form.expression.operator.option.neq.alias_not_label") }, + ]; + + default: + return []; + } + }; + + const handleFormChange = (_: undefined, values: ConditionFormValues) => { + setValue(formValuesToExpr(values)); + }; + + useImperativeHandle(ref, () => { + return { + validate: async () => { + await formInst.validateFields(); + }, + } as ConditionNodeConfigFormExpressionEditorInstance; + }); + + return ( +
+ 1}> + + + {t("workflow_node.condition.form.expression.logical_operator.option.and.label")} + {t("workflow_node.condition.form.expression.logical_operator.option.or.label")} + + + + + + {(fields, { add, remove }) => ( +
+ {fields.map(({ key, name: index, ...rest }) => ( +
+ {/* 左:变量选择器 */} + + + + ); + }} + + + {/* 右:输入控件,根据变量类型决定组件 */} + { + return prevValues.conditions?.[index]?.leftSelector !== currentValues.conditions?.[index]?.leftSelector; + }} + > + {({ getFieldValue }) => { + const leftSelector = getFieldValue(["conditions", index, "leftSelector"]); + const valueType = getValueTypeBySelector(leftSelector); + + return ( + + {valueType === "string" ? ( + + ) : valueType === "number" ? ( + + ) : valueType === "boolean" ? ( + + ) : ( + + )} + + ); + }} + + +
+ ))} + + + + +
+ )} +
+
+ ); + } +); + +export default ConditionNodeConfigFormExpressionEditor; diff --git a/ui/src/components/workflow/node/DeployNode.tsx b/ui/src/components/workflow/node/DeployNode.tsx index b04516fb..f495c040 100644 --- a/ui/src/components/workflow/node/DeployNode.tsx +++ b/ui/src/components/workflow/node/DeployNode.tsx @@ -24,10 +24,10 @@ const DeployNode = ({ node, disabled }: DeployNodeProps) => { const formRef = useRef(null); const [formPending, setFormPending] = useState(false); + const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForDeploy; const [drawerOpen, setDrawerOpen] = useState(false); const [drawerFooterShow, setDrawerFooterShow] = useState(true); - const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForDeploy; useEffect(() => { setDrawerFooterShow(!!(node.config as WorkflowNodeConfigForDeploy)?.provider); @@ -86,8 +86,9 @@ const DeployNode = ({ node, disabled }: DeployNodeProps) => { { setDrawerFooterShow(!!(node.config as WorkflowNodeConfigForDeploy)?.provider); setDrawerOpen(open); }} - getFormValues={() => formRef.current!.getFieldsValue()} > diff --git a/ui/src/components/workflow/node/DeployNodeConfigForm.tsx b/ui/src/components/workflow/node/DeployNodeConfigForm.tsx index 0443327e..33fefcf0 100644 --- a/ui/src/components/workflow/node/DeployNodeConfigForm.tsx +++ b/ui/src/components/workflow/node/DeployNodeConfigForm.tsx @@ -1,7 +1,7 @@ import { forwardRef, memo, useEffect, useImperativeHandle, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { PlusOutlined as PlusOutlinedIcon, QuestionCircleOutlined as QuestionCircleOutlinedIcon } from "@ant-design/icons"; -import { Button, Divider, Flex, Form, type FormInstance, Select, Switch, Tooltip, Typography } from "antd"; +import { Button, Divider, Flex, Form, type FormInstance, Select, Switch, Tooltip, Typography, theme } from "antd"; import { createSchemaFieldRule } from "antd-zod"; import { z } from "zod"; @@ -11,7 +11,7 @@ import DeploymentProviderPicker from "@/components/provider/DeploymentProviderPi import DeploymentProviderSelect from "@/components/provider/DeploymentProviderSelect.tsx"; import Show from "@/components/Show"; import { ACCESS_USAGES, DEPLOYMENT_PROVIDERS, accessProvidersMap, deploymentProvidersMap } from "@/domain/provider"; -import { type WorkflowNode, type WorkflowNodeConfigForDeploy } from "@/domain/workflow"; +import { type WorkflowNodeConfigForDeploy, WorkflowNodeType } from "@/domain/workflow"; import { useAntdForm, useAntdFormName, useZustandShallowSelector } from "@/hooks"; import { useWorkflowStore } from "@/stores/workflow"; @@ -125,14 +125,9 @@ const DeployNodeConfigForm = forwardRef { const { t } = useTranslation(); - const { getWorkflowOuptutBeforeId } = useWorkflowStore(useZustandShallowSelector(["updateNode", "getWorkflowOuptutBeforeId"])); + const { token: themeToken } = theme.useToken(); - // TODO: 优化此处逻辑 - const [previousNodes, setPreviousNodes] = useState([]); - useEffect(() => { - const previousNodes = getWorkflowOuptutBeforeId(nodeId, "certificate"); - setPreviousNodes(previousNodes); - }, [nodeId]); + const { getWorkflowOuptutBeforeId } = useWorkflowStore(useZustandShallowSelector(["updateNode", "getWorkflowOuptutBeforeId"])); const formSchema = z.object({ certificate: z @@ -170,6 +165,24 @@ const DeployNodeConfigForm = forwardRef { + const previousNodes = getWorkflowOuptutBeforeId(nodeId, "certificate"); + return previousNodes + .filter((node) => node.type === WorkflowNodeType.Apply || node.type === WorkflowNodeType.Upload) + .map((item) => { + return { + label: item.name, + options: (item.outputs ?? [])?.map((output) => { + return { + label: output.label, + value: `${item.id}#${output.name}`, + }; + }), + }; + }) + .filter((group) => group.options.length > 0); + }, [nodeId]); + const [nestedFormInst] = Form.useForm(); const nestedFormName = useAntdFormName({ form: nestedFormInst, name: "workflowNodeDeployConfigFormProviderConfigForm" }); const nestedFormEl = useMemo(() => { @@ -487,17 +500,15 @@ const DeployNodeConfigForm = forwardRef} > ({ diff --git a/ui/src/components/workflow/node/DeployNodeConfigFormWangsuCDNConfig.tsx b/ui/src/components/workflow/node/DeployNodeConfigFormWangsuCDNConfig.tsx index d64e6eba..36d663b5 100644 --- a/ui/src/components/workflow/node/DeployNodeConfigFormWangsuCDNConfig.tsx +++ b/ui/src/components/workflow/node/DeployNodeConfigFormWangsuCDNConfig.tsx @@ -18,7 +18,7 @@ export type DeployNodeConfigFormWangsuCDNConfigProps = { onValuesChange?: (values: DeployNodeConfigFormWangsuCDNConfigFieldValues) => void; }; -const MULTIPLE_INPUT_DELIMITER = ";"; +const MULTIPLE_INPUT_SEPARATOR = ";"; const initFormModel = (): DeployNodeConfigFormWangsuCDNConfigFieldValues => { return { @@ -42,7 +42,7 @@ const DeployNodeConfigFormWangsuCDNConfig = ({ .refine((v) => { if (!v) return false; return String(v) - .split(MULTIPLE_INPUT_DELIMITER) + .split(MULTIPLE_INPUT_SEPARATOR) .every((e) => validDomainName(e)); }, t("workflow_node.deploy.form.wangsu_cdn_domains.placeholder")), }); diff --git a/ui/src/components/workflow/node/MonitorNode.tsx b/ui/src/components/workflow/node/MonitorNode.tsx index 68feb842..39fb159e 100644 --- a/ui/src/components/workflow/node/MonitorNode.tsx +++ b/ui/src/components/workflow/node/MonitorNode.tsx @@ -23,9 +23,9 @@ const MonitorNode = ({ node, disabled }: MonitorNodeProps) => { const formRef = useRef(null); const [formPending, setFormPending] = useState(false); + const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForMonitor; const [drawerOpen, setDrawerOpen] = useState(false); - const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForMonitor; const wrappedEl = useMemo(() => { if (node.type !== WorkflowNodeType.Monitor) { @@ -74,12 +74,12 @@ const MonitorNode = ({ node, disabled }: MonitorNodeProps) => { setDrawerOpen(open)} - getFormValues={() => formRef.current!.getFieldsValue()} > diff --git a/ui/src/components/workflow/node/NotifyNode.tsx b/ui/src/components/workflow/node/NotifyNode.tsx index da48552d..89326b50 100644 --- a/ui/src/components/workflow/node/NotifyNode.tsx +++ b/ui/src/components/workflow/node/NotifyNode.tsx @@ -25,9 +25,9 @@ const NotifyNode = ({ node, disabled }: NotifyNodeProps) => { const formRef = useRef(null); const [formPending, setFormPending] = useState(false); + const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForNotify; const [drawerOpen, setDrawerOpen] = useState(false); - const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForNotify; const wrappedEl = useMemo(() => { if (node.type !== WorkflowNodeType.Notify) { @@ -82,12 +82,12 @@ const NotifyNode = ({ node, disabled }: NotifyNodeProps) => { setDrawerOpen(open)} - getFormValues={() => formRef.current!.getFieldsValue()} > diff --git a/ui/src/components/workflow/node/StartNode.tsx b/ui/src/components/workflow/node/StartNode.tsx index 900793fa..4b920bd9 100644 --- a/ui/src/components/workflow/node/StartNode.tsx +++ b/ui/src/components/workflow/node/StartNode.tsx @@ -23,9 +23,9 @@ const StartNode = ({ node, disabled }: StartNodeProps) => { const formRef = useRef(null); const [formPending, setFormPending] = useState(false); + const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForStart; const [drawerOpen, setDrawerOpen] = useState(false); - const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForStart; const wrappedEl = useMemo(() => { if (node.type !== WorkflowNodeType.Start) { @@ -83,12 +83,12 @@ const StartNode = ({ node, disabled }: StartNodeProps) => { setDrawerOpen(open)} - getFormValues={() => formRef.current!.getFieldsValue()} > diff --git a/ui/src/components/workflow/node/UploadNode.tsx b/ui/src/components/workflow/node/UploadNode.tsx index 0197a8c4..6936f147 100644 --- a/ui/src/components/workflow/node/UploadNode.tsx +++ b/ui/src/components/workflow/node/UploadNode.tsx @@ -23,9 +23,9 @@ const UploadNode = ({ node, disabled }: UploadNodeProps) => { const formRef = useRef(null); const [formPending, setFormPending] = useState(false); + const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForUpload; const [drawerOpen, setDrawerOpen] = useState(false); - const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForUpload; const wrappedEl = useMemo(() => { if (node.type !== WorkflowNodeType.Upload) { @@ -74,12 +74,12 @@ const UploadNode = ({ node, disabled }: UploadNodeProps) => { setDrawerOpen(open)} - getFormValues={() => formRef.current!.getFieldsValue()} > diff --git a/ui/src/components/workflow/node/_SharedNode.tsx b/ui/src/components/workflow/node/_SharedNode.tsx index 44c894ef..72f4b967 100644 --- a/ui/src/components/workflow/node/_SharedNode.tsx +++ b/ui/src/components/workflow/node/_SharedNode.tsx @@ -33,7 +33,7 @@ const SharedNodeTitle = ({ className, style, node, disabled }: SharedNodeTitlePr const handleBlur = (e: React.FocusEvent) => { const oldName = node.name; - const newName = e.target.innerText.trim().substring(0, 64) || oldName; + const newName = e.target.innerText.replaceAll("\r", "").replaceAll("\n", "").trim().substring(0, 64) || oldName; if (oldName === newName) { return; } @@ -45,9 +45,16 @@ const SharedNodeTitle = ({ className, style, node, disabled }: SharedNodeTitlePr ); }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.code === "Enter") { + e.preventDefault(); + e.currentTarget.blur(); + } + }; + return (
-
+
{node.name}
@@ -91,7 +98,7 @@ const SharedNodeMenu = ({ menus, trigger, node, disabled, branchId, branchIndex, const handleRenameConfirm = async () => { const oldName = node.name; - const newName = nameRef.current?.trim()?.substring(0, 64) || oldName; + const newName = nameRef.current?.replaceAll("\r", "")?.replaceAll("\n", "").trim()?.substring(0, 64) || oldName; if (oldName === newName) { return; } @@ -195,7 +202,7 @@ const SharedNodeMenu = ({ menus, trigger, node, disabled, branchId, branchIndex, }; // #endregion -// #region Wrapper +// #region Block type SharedNodeBlockProps = SharedNodeProps & { children: React.ReactNode; onClick?: (e: React.MouseEvent) => void; @@ -245,7 +252,7 @@ type SharedNodeEditDrawerProps = SharedNodeProps & { pending?: boolean; onOpenChange?: (open: boolean) => void; onConfirm: () => void | Promise; - getFormValues: () => NonNullable; + getConfigNewValues: () => NonNullable; // 用于获取节点配置的新值,以便在抽屉关闭前进行对比,决定是否提示保存 }; const SharedNodeConfigDrawer = ({ @@ -256,7 +263,7 @@ const SharedNodeConfigDrawer = ({ loading, pending, onConfirm, - getFormValues, + getConfigNewValues, ...props }: SharedNodeEditDrawerProps) => { const { t } = useTranslation(); @@ -284,7 +291,7 @@ const SharedNodeConfigDrawer = ({ if (pending) return; const oldValues = JSON.parse(JSON.stringify(node.config ?? {})); - const newValues = JSON.parse(JSON.stringify(getFormValues())); + const newValues = JSON.parse(JSON.stringify(getConfigNewValues())); const changed = !isEqual(oldValues, {}) && !isEqual(oldValues, newValues); const { promise, resolve, reject } = Promise.withResolvers(); diff --git a/ui/src/domain/workflow.ts b/ui/src/domain/workflow.ts index 4dea7f64..d3b9f822 100644 --- a/ui/src/domain/workflow.ts +++ b/ui/src/domain/workflow.ts @@ -69,7 +69,7 @@ const workflowNodeTypeDefaultInputs: Map = n name: "certificate", type: "certificate", required: true, - label: i18n.t("workflow.variables.certificate.label"), + label: i18n.t("workflow.variables.type.certificate.label"), }, ], ], @@ -84,7 +84,7 @@ const workflowNodeTypeDefaultOutputs: Map = name: "certificate", type: "certificate", required: true, - label: i18n.t("workflow.variables.certificate.label"), + label: i18n.t("workflow.variables.type.certificate.label"), }, ], ], @@ -95,7 +95,7 @@ const workflowNodeTypeDefaultOutputs: Map = name: "certificate", type: "certificate", required: true, - label: i18n.t("workflow.variables.certificate.label"), + label: i18n.t("workflow.variables.type.certificate.label"), }, ], ], @@ -106,7 +106,7 @@ const workflowNodeTypeDefaultOutputs: Map = name: "certificate", type: "certificate", required: true, - label: i18n.t("workflow.variables.certificate.label"), + label: i18n.t("workflow.variables.type.certificate.label"), }, ], ], @@ -188,7 +188,7 @@ export type WorkflowNodeConfigForNotify = { }; export type WorkflowNodeConfigForCondition = { - expression: Expr; + expression?: Expr; }; export type WorkflowNodeConfigForBranch = never; @@ -204,96 +204,35 @@ export type WorkflowNodeIO = { valueSelector?: WorkflowNodeIOValueSelector; }; -export const VALUE_TYPES = Object.freeze({ - STRING: "string", - NUMBER: "number", - BOOLEAN: "boolean", -} as const); - -export type WorkflowNodeIoValueType = (typeof VALUE_TYPES)[keyof typeof VALUE_TYPES]; - -export type WorkflowNodeIOValueSelector = { - id: string; - name: string; - type: WorkflowNodeIoValueType; -}; - -type WorkflowNodeIOOptions = { - label: string; - value: string; -}; - -export const workflowNodeIOOptions = (node: WorkflowNode) => { - const rs = { - label: node.name, - options: Array(), - }; - - if (node.outputs) { - for (const output of node.outputs) { - switch (output.type) { - case "certificate": - rs.options.push({ - label: `${node.name} - ${output.label} - ${i18n.t("workflow.variables.is_validated.label")}`, - value: `${node.id}#${output.name}.validated#boolean`, - }); - - rs.options.push({ - label: `${node.name} - ${output.label} - ${i18n.t("workflow.variables.days_left.label")}`, - value: `${node.id}#${output.name}.daysLeft#number`, - }); - break; - default: - rs.options.push({ - label: `${node.name} - ${output.label}`, - value: `${node.id}#${output.name}#${output.type}`, - }); - break; - } - } - } - - return rs; -}; - +export type WorkflowNodeIOValueSelector = ExprValueSelector; // #endregion -// #region Condition expression - -export type Value = string | number | boolean; - -export type ComparisonOperator = ">" | "<" | ">=" | "<=" | "==" | "!=" | "is"; - -export enum LogicalOperator { - And = "and", - Or = "or", - Not = "not", -} - +// #region Expression export enum ExprType { - Const = "const", - Var = "var", - Compare = "compare", + Constant = "const", + Variant = "var", + Comparison = "comparison", Logical = "logical", Not = "not", } -export type ConstExpr = { type: ExprType.Const; value: string; valueType: WorkflowNodeIoValueType }; -export type VarExpr = { type: ExprType.Var; selector: WorkflowNodeIOValueSelector }; -export type CompareExpr = { type: ExprType.Compare; op: ComparisonOperator; left: Expr; right: Expr }; -export type LogicalExpr = { type: ExprType.Logical; op: LogicalOperator; left: Expr; right: Expr }; +export type ExprValue = string | number | boolean; +export type ExprValueType = "string" | "number" | "boolean"; +export type ExprValueSelector = { + id: string; + name: string; + type: ExprValueType; +}; + +export type ExprComparisonOperator = "gt" | "gte" | "lt" | "lte" | "eq" | "neq"; +export type ExprLogicalOperator = "and" | "or" | "not"; + +export type ConstantExpr = { type: ExprType.Constant; value: string; valueType: ExprValueType }; +export type VariantExpr = { type: ExprType.Variant; selector: ExprValueSelector }; +export type ComparisonExpr = { type: ExprType.Comparison; operator: ExprComparisonOperator; left: Expr; right: Expr }; +export type LogicalExpr = { type: ExprType.Logical; operator: ExprLogicalOperator; left: Expr; right: Expr }; export type NotExpr = { type: ExprType.Not; expr: Expr }; - -export type Expr = ConstExpr | VarExpr | CompareExpr | LogicalExpr | NotExpr; - -export const isConstExpr = (expr: Expr): expr is ConstExpr => { - return expr.type === ExprType.Const; -}; - -export const isVarExpr = (expr: Expr): expr is VarExpr => { - return expr.type === ExprType.Var; -}; - +export type Expr = ConstantExpr | VariantExpr | ComparisonExpr | LogicalExpr | NotExpr; // #endregion const isBranchLike = (node: WorkflowNode) => { @@ -352,8 +291,8 @@ export const newNode = (nodeType: WorkflowNodeType, options: NewNodeOptions = {} switch (nodeType) { case WorkflowNodeType.Apply: case WorkflowNodeType.Upload: - case WorkflowNodeType.Deploy: case WorkflowNodeType.Monitor: + case WorkflowNodeType.Deploy: { node.inputs = workflowNodeTypeDefaultInputs.get(nodeType); node.outputs = workflowNodeTypeDefaultOutputs.get(nodeType); @@ -545,20 +484,24 @@ export const removeBranch = (node: WorkflowNode, branchNodeId: string, branchInd }); }; -const typeEqual = (a: WorkflowNodeIO, t: string) => { - if (t === "all") { - return true; - } - if (a.type === t) { - return true; - } - return false; -}; - -export const getOutputBeforeNodeId = (root: WorkflowNode, nodeId: string, type: string = "all"): WorkflowNode[] => { +export const getOutputBeforeNodeId = (root: WorkflowNode, nodeId: string, typeFilter?: string | string[]): WorkflowNode[] => { // 某个分支的节点,不应该能获取到相邻分支上节点的输出 const outputs: WorkflowNode[] = []; + const filter = (io: WorkflowNodeIO) => { + if (typeFilter == null) { + return true; + } + + if (Array.isArray(typeFilter) && typeFilter.includes(io.type)) { + return true; + } else if (io.type === typeFilter) { + return true; + } + + return false; + }; + const traverse = (current: WorkflowNode, output: WorkflowNode[]) => { if (!current) { return false; @@ -567,10 +510,10 @@ export const getOutputBeforeNodeId = (root: WorkflowNode, nodeId: string, type: return true; } - if (current.type !== WorkflowNodeType.Branch && current.outputs && current.outputs.some((io) => typeEqual(io, type))) { + if (current.type !== WorkflowNodeType.Branch && current.outputs && current.outputs.some((io) => filter(io))) { output.push({ ...current, - outputs: current.outputs.filter((io) => typeEqual(io, type)), + outputs: current.outputs.filter((io) => filter(io)), }); } diff --git a/ui/src/domain/workflowExpr.ts b/ui/src/domain/workflowExpr.ts new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/ui/src/domain/workflowExpr.ts @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/i18n/locales/en/index.ts b/ui/src/i18n/locales/en/index.ts index f038efc7..4eaeced5 100644 --- a/ui/src/i18n/locales/en/index.ts +++ b/ui/src/i18n/locales/en/index.ts @@ -8,6 +8,7 @@ import nlsSettings from "./nls.settings.json"; import nlsWorkflow from "./nls.workflow.json"; import nlsWorkflowNodes from "./nls.workflow.nodes.json"; import nlsWorkflowRuns from "./nls.workflow.runs.json"; +import nlsWorkflowVars from "./nls.workflow.vars.json"; export default Object.freeze({ ...nlsCommon, @@ -16,8 +17,9 @@ export default Object.freeze({ ...nlsSettings, ...nlsProvider, ...nlsAccess, + ...nlsCertificate, ...nlsWorkflow, ...nlsWorkflowNodes, ...nlsWorkflowRuns, - ...nlsCertificate, + ...nlsWorkflowVars, }); diff --git a/ui/src/i18n/locales/en/nls.workflow.json b/ui/src/i18n/locales/en/nls.workflow.json index cdf722a0..b4f9d7e6 100644 --- a/ui/src/i18n/locales/en/nls.workflow.json +++ b/ui/src/i18n/locales/en/nls.workflow.json @@ -53,9 +53,5 @@ "workflow.detail.orchestration.action.run": "Run", "workflow.detail.orchestration.action.run.confirm": "You have unreleased changes. Do you really want to run this workflow based on the latest released version?", "workflow.detail.orchestration.action.run.prompt": "Running... Please check the history later", - "workflow.detail.runs.tab": "History runs", - - "workflow.variables.is_validated.label": "Is valid", - "workflow.variables.days_left.label": "Days left", - "workflow.variables.certificate.label": "Certificate" + "workflow.detail.runs.tab": "History runs" } diff --git a/ui/src/i18n/locales/en/nls.workflow.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json index 5b6c870c..626e9b68 100644 --- a/ui/src/i18n/locales/en/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/en/nls.workflow.nodes.json @@ -871,31 +871,32 @@ "workflow_node.end.label": "End", "workflow_node.end.default_name": "End", - "workflow_node.branch.label": "Parallel branch", - "workflow_node.branch.default_name": "Parallel", + "workflow_node.branch.label": "Parallel/Conditional branch", + "workflow_node.branch.default_name": "Branch", "workflow_node.condition.label": "Branch", "workflow_node.condition.default_name": "Branch", - "workflow_node.condition.form.variable.placeholder": "Please select variable", - "workflow_node.condition.form.variable.errmsg": "Please select variable", - "workflow_node.condition.form.operator.errmsg": "Please select operator", - "workflow_node.condition.form.value.errmsg": "Please enter value", - "workflow_node.condition.form.value.string.placeholder": "Please enter value", - "workflow_node.condition.form.value.number.placeholder": "Please enter value", - "workflow_node.condition.form.value.boolean.placeholder": "Please select value", - "workflow_node.condition.form.value.boolean.true": "True", - "workflow_node.condition.form.value.boolean.false": "False", - "workflow_node.condition.form.add_condition.button": "Add condition", - "workflow_node.condition.form.logical_operator.label": "Logical operator", - "workflow_node.condition.form.logical_operator.and": "Meet all conditions (AND)", - "workflow_node.condition.form.logical_operator.or": "Meet any condition (OR)", - "workflow_node.condition.form.comparison.equal": "Equal", - "workflow_node.condition.form.comparison.not_equal": "Not equal", - "workflow_node.condition.form.comparison.greater_than": "Greater than", - "workflow_node.condition.form.comparison.greater_than_or_equal": "Greater than or equal", - "workflow_node.condition.form.comparison.less_than": "Less than", - "workflow_node.condition.form.comparison.less_than_or_equal": "Less than or equal", - "workflow_node.condition.form.comparison.is": "Is", + "workflow_node.condition.form.expression.label": "Conditions to enter the branch", + "workflow_node.condition.form.expression.logical_operator.errmsg": "Please select logical operator of conditions", + "workflow_node.condition.form.expression.logical_operator.option.and.label": "Meeting all of the conditions (AND)", + "workflow_node.condition.form.expression.logical_operator.option.or.label": "Meeting any of the conditions (OR)", + "workflow_node.condition.form.expression.variable.placeholder": "Please select", + "workflow_node.condition.form.expression.variable.errmsg": "Please select variable", + "workflow_node.condition.form.expression.operator.placeholder": "Please select", + "workflow_node.condition.form.expression.operator.errmsg": "Please select operator", + "workflow_node.condition.form.expression.operator.option.eq.label": "equal to", + "workflow_node.condition.form.expression.operator.option.eq.alias_is_label": "is", + "workflow_node.condition.form.expression.operator.option.neq.label": "not equal to", + "workflow_node.condition.form.expression.operator.option.neq.alias_not_label": "is not", + "workflow_node.condition.form.expression.operator.option.gt.label": "greater than", + "workflow_node.condition.form.expression.operator.option.gte.label": "greater than or equal to", + "workflow_node.condition.form.expression.operator.option.lt.label": "less than", + "workflow_node.condition.form.expression.operator.option.lte.label": "less than or equal to", + "workflow_node.condition.form.expression.value.placeholder": "Please enter", + "workflow_node.condition.form.expression.value.errmsg": "Please enter value", + "workflow_node.condition.form.expression.value.option.true.label": "True", + "workflow_node.condition.form.expression.value.option.false.label": "False", + "workflow_node.condition.form.expression.add_condition.button": "Add condition", "workflow_node.execute_result_branch.label": "Execution result branch", "workflow_node.execute_result_branch.default_name": "Execution result branch", diff --git a/ui/src/i18n/locales/en/nls.workflow.vars.json b/ui/src/i18n/locales/en/nls.workflow.vars.json new file mode 100644 index 00000000..a96d8ba5 --- /dev/null +++ b/ui/src/i18n/locales/en/nls.workflow.vars.json @@ -0,0 +1,6 @@ +{ + "workflow.variables.type.certificate.label": "Certificate", + + "workflow.variables.selector.validity.label": "Validity", + "workflow.variables.selector.days_left.label": "Days left" +} diff --git a/ui/src/i18n/locales/zh/index.ts b/ui/src/i18n/locales/zh/index.ts index f038efc7..4eaeced5 100644 --- a/ui/src/i18n/locales/zh/index.ts +++ b/ui/src/i18n/locales/zh/index.ts @@ -8,6 +8,7 @@ import nlsSettings from "./nls.settings.json"; import nlsWorkflow from "./nls.workflow.json"; import nlsWorkflowNodes from "./nls.workflow.nodes.json"; import nlsWorkflowRuns from "./nls.workflow.runs.json"; +import nlsWorkflowVars from "./nls.workflow.vars.json"; export default Object.freeze({ ...nlsCommon, @@ -16,8 +17,9 @@ export default Object.freeze({ ...nlsSettings, ...nlsProvider, ...nlsAccess, + ...nlsCertificate, ...nlsWorkflow, ...nlsWorkflowNodes, ...nlsWorkflowRuns, - ...nlsCertificate, + ...nlsWorkflowVars, }); diff --git a/ui/src/i18n/locales/zh/nls.workflow.json b/ui/src/i18n/locales/zh/nls.workflow.json index e86e796a..46cdc228 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.json +++ b/ui/src/i18n/locales/zh/nls.workflow.json @@ -53,9 +53,5 @@ "workflow.detail.orchestration.action.run": "执行", "workflow.detail.orchestration.action.run.confirm": "你有尚未发布的更改。确定要以最近一次发布的版本继续执行吗?", "workflow.detail.orchestration.action.run.prompt": "执行中……请稍后查看执行历史", - "workflow.detail.runs.tab": "执行历史", - - "workflow.variables.is_validated.label": "是否有效", - "workflow.variables.days_left.label": "剩余天数", - "workflow.variables.certificate.label": "证书" + "workflow.detail.runs.tab": "执行历史" } diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index 0d7ce68c..7710e386 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -3,7 +3,7 @@ "workflow_node.branch.add_node": "添加节点", "workflow_node.action.rename_node": "重命名", "workflow_node.action.remove_node": "删除节点", - "workflow_node.action.add_branch": "添加并行分支", + "workflow_node.action.add_branch": "添加分支", "workflow_node.action.rename_branch": "重命名", "workflow_node.action.remove_branch": "删除分支", @@ -707,7 +707,7 @@ "workflow_node.deploy.form.ucloud_us3_domain.label": "优刻得 US3 自定义域名", "workflow_node.deploy.form.ucloud_us3_domain.placeholder": "请输入优刻得 US3 自定义域名", "workflow_node.deploy.form.ucloud_us3_domain.tooltip": "这是什么?请参阅 https://console.ucloud.cn/ufile", - "workflow_node.deploy.form.unicloud_webhost.guide": "小贴士:由于 uniCloud 未提供相关 API,这里将使用网页模拟登录方式部署,但无法保证稳定性。如遇 uniCloud 接口变更,请到 GitHub 发起 Issue 告知。", + "workflow_node.deploy.form.unicloud_webhost.guide": "小贴士:由于 uniCloud 未公开相关 API,这里将使用网页模拟登录方式部署,但无法保证稳定性。如遇 uniCloud 接口变更,请到 GitHub 发起 Issue 告知。", "workflow_node.deploy.form.unicloud_webhost_space_provider.label": "uniCloud 服务空间提供商", "workflow_node.deploy.form.unicloud_webhost_space_provider.placeholder": "请选择 uniCloud 服务空间提供商", "workflow_node.deploy.form.unicloud_webhost_space_provider.option.aliyun.label": "阿里云", @@ -717,11 +717,11 @@ "workflow_node.deploy.form.unicloud_webhost_space_id.tooltip": "这是什么?请参阅 https://doc.dcloud.net.cn/uniCloud/concepts/space.html", "workflow_node.deploy.form.unicloud_webhost_domain.label": "uniCloud 前端网页托管网站域名", "workflow_node.deploy.form.unicloud_webhost_domain.placeholder": "请输入 uniCloud 前端网页托管网站域名", - "workflow_node.deploy.form.upyun_cdn.guide": "小贴士:由于又拍云未提供相关 API,这里将使用网页模拟登录方式部署,但无法保证稳定性。如遇又拍云接口变更,请到 GitHub 发起 Issue 告知。", + "workflow_node.deploy.form.upyun_cdn.guide": "小贴士:由于又拍云未公开相关 API,这里将使用网页模拟登录方式部署,但无法保证稳定性。如遇又拍云接口变更,请到 GitHub 发起 Issue 告知。", "workflow_node.deploy.form.upyun_cdn_domain.label": "又拍云 CDN 加速域名", "workflow_node.deploy.form.upyun_cdn_domain.placeholder": "请输入又拍云 CDN 加速域名(支持泛域名)", "workflow_node.deploy.form.upyun_cdn_domain.tooltip": "这是什么?请参阅 https://console.upyun.com/services/cdn/", - "workflow_node.deploy.form.upyun_file.guide": "小贴士:由于又拍云未提供相关 API,这里将使用网页模拟登录方式部署,但无法保证稳定性。如遇又拍云接口变更,请到 GitHub 发起 Issue 告知。", + "workflow_node.deploy.form.upyun_file.guide": "小贴士:由于又拍云未公开相关 API,这里将使用网页模拟登录方式部署,但无法保证稳定性。如遇又拍云接口变更,请到 GitHub 发起 Issue 告知。", "workflow_node.deploy.form.upyun_file_domain.label": "又拍云云存储加速域名", "workflow_node.deploy.form.upyun_file_domain.placeholder": "请输入又拍云云存储加速域名", "workflow_node.deploy.form.upyun_file_domain.tooltip": "这是什么?请参阅 https://console.upyun.com/services/file/", @@ -870,31 +870,32 @@ "workflow_node.end.label": "结束", "workflow_node.end.default_name": "结束", - "workflow_node.branch.label": "并行分支", - "workflow_node.branch.default_name": "并行", + "workflow_node.branch.label": "并行/条件分支", + "workflow_node.branch.default_name": "分支", "workflow_node.condition.label": "分支", "workflow_node.condition.default_name": "分支", - "workflow_node.condition.form.variable.placeholder": "选择变量", - "workflow_node.condition.form.variable.errmsg": "请选择变量", - "workflow_node.condition.form.operator.errmsg": "请选择操作符", - "workflow_node.condition.form.value.errmsg": "请输入值", - "workflow_node.condition.form.value.string.placeholder": "输入值", - "workflow_node.condition.form.value.number.placeholder": "输入数值", - "workflow_node.condition.form.value.boolean.placeholder": "选择值", - "workflow_node.condition.form.value.boolean.true": "是", - "workflow_node.condition.form.value.boolean.false": "否", - "workflow_node.condition.form.add_condition.button": "添加条件", - "workflow_node.condition.form.logical_operator.label": "条件逻辑", - "workflow_node.condition.form.logical_operator.and": "满足所有条件 (AND)", - "workflow_node.condition.form.logical_operator.or": "满足任一条件 (OR)", - "workflow_node.condition.form.comparison.equal": "等于", - "workflow_node.condition.form.comparison.not_equal": "不等于", - "workflow_node.condition.form.comparison.greater_than": "大于", - "workflow_node.condition.form.comparison.greater_than_or_equal": "大于等于", - "workflow_node.condition.form.comparison.less_than": "小于", - "workflow_node.condition.form.comparison.less_than_or_equal": "小于等于", - "workflow_node.condition.form.comparison.is": "为", + "workflow_node.condition.form.expression.label": "分支进入条件", + "workflow_node.condition.form.expression.logical_operator.errmsg": "请选择条件组合方式", + "workflow_node.condition.form.expression.logical_operator.option.and.label": "满足以下所有条件 (AND)", + "workflow_node.condition.form.expression.logical_operator.option.or.label": "满足以下任一条件 (OR)", + "workflow_node.condition.form.expression.variable.placeholder": "请选择", + "workflow_node.condition.form.expression.variable.errmsg": "请选择变量", + "workflow_node.condition.form.expression.operator.placeholder": "请选择", + "workflow_node.condition.form.expression.operator.errmsg": "请选择运算符", + "workflow_node.condition.form.expression.operator.option.eq.label": "等于", + "workflow_node.condition.form.expression.operator.option.eq.alias_is_label": "为", + "workflow_node.condition.form.expression.operator.option.neq.label": "不等于", + "workflow_node.condition.form.expression.operator.option.neq.alias_not_label": "不为", + "workflow_node.condition.form.expression.operator.option.gt.label": "大于", + "workflow_node.condition.form.expression.operator.option.gte.label": "大于等于", + "workflow_node.condition.form.expression.operator.option.lt.label": "小于", + "workflow_node.condition.form.expression.operator.option.lte.label": "小于等于", + "workflow_node.condition.form.expression.value.placeholder": "请输入", + "workflow_node.condition.form.expression.value.errmsg": "请输入值", + "workflow_node.condition.form.expression.value.option.true.label": "真", + "workflow_node.condition.form.expression.value.option.false.label": "假", + "workflow_node.condition.form.expression.add_condition.button": "添加条件", "workflow_node.execute_result_branch.label": "执行结果分支", "workflow_node.execute_result_branch.default_name": "执行结果分支", diff --git a/ui/src/i18n/locales/zh/nls.workflow.vars.json b/ui/src/i18n/locales/zh/nls.workflow.vars.json new file mode 100644 index 00000000..eddfc585 --- /dev/null +++ b/ui/src/i18n/locales/zh/nls.workflow.vars.json @@ -0,0 +1,6 @@ +{ + "workflow.variables.type.certificate.label": "证书", + + "workflow.variables.selector.validity.label": "有效性", + "workflow.variables.selector.days_left.label": "剩余天数" +} diff --git a/ui/src/pages/workflows/WorkflowDetail.tsx b/ui/src/pages/workflows/WorkflowDetail.tsx index 832269d0..91e8d746 100644 --- a/ui/src/pages/workflows/WorkflowDetail.tsx +++ b/ui/src/pages/workflows/WorkflowDetail.tsx @@ -265,7 +265,7 @@ const WorkflowDetail = () => { body: { position: "relative", height: "100%", - padding: 0, + padding: initialized ? 0 : undefined, }, }} loading={!initialized} diff --git a/ui/src/stores/workflow/index.ts b/ui/src/stores/workflow/index.ts index d20fec16..67bc25f9 100644 --- a/ui/src/stores/workflow/index.ts +++ b/ui/src/stores/workflow/index.ts @@ -32,7 +32,7 @@ export type WorkflowState = { addBranch: (branchId: string) => void; removeBranch: (branchId: string, index: number) => void; - getWorkflowOuptutBeforeId: (nodeId: string, type?: string) => WorkflowNode[]; + getWorkflowOuptutBeforeId: (nodeId: string, typeFilter?: string | string[]) => WorkflowNode[]; }; export const useWorkflowStore = create((set, get) => ({ @@ -243,7 +243,7 @@ export const useWorkflowStore = create((set, get) => ({ }); }, - getWorkflowOuptutBeforeId: (nodeId: string, type: string = "all") => { - return getOutputBeforeNodeId(get().workflow.draft as WorkflowNode, nodeId, type); + getWorkflowOuptutBeforeId: (nodeId: string, typeFilter?: string | string[]) => { + return getOutputBeforeNodeId(get().workflow.draft as WorkflowNode, nodeId, typeFilter); }, })); From f885b49daf176a56c55845b7554323eac1411c6e Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Sun, 1 Jun 2025 22:59:24 +0800 Subject: [PATCH 14/14] feat: add certtest workflow template --- ui/public/imgs/workflow/tpl-blank.png | Bin 5262 -> 9218 bytes ui/public/imgs/workflow/tpl-certtest.png | Bin 0 -> 20833 bytes ui/public/imgs/workflow/tpl-standard.png | Bin 12858 -> 20723 bytes ui/src/domain/workflow.ts | 154 ++++++++++++++++-- ui/src/domain/workflowExpr.ts | 1 - ui/src/i18n/locales/en/nls.workflow.json | 2 + .../i18n/locales/en/nls.workflow.nodes.json | 2 + ui/src/i18n/locales/zh/nls.workflow.json | 4 +- .../i18n/locales/zh/nls.workflow.nodes.json | 10 +- ui/src/pages/workflows/WorkflowNew.tsx | 34 +++- 10 files changed, 184 insertions(+), 23 deletions(-) create mode 100644 ui/public/imgs/workflow/tpl-certtest.png delete mode 100644 ui/src/domain/workflowExpr.ts diff --git a/ui/public/imgs/workflow/tpl-blank.png b/ui/public/imgs/workflow/tpl-blank.png index 8f683ce6fcb0f5e62d469d415c6277fccf7e0770..ee6568a9804f982e74f4f9a5ee78fd08640a272e 100644 GIT binary patch literal 9218 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 F^iR&aMN9wy literal 5262 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 diff --git a/ui/public/imgs/workflow/tpl-certtest.png b/ui/public/imgs/workflow/tpl-certtest.png new file mode 100644 index 0000000000000000000000000000000000000000..da7bfada55b7d0ff800363c414c9bca88b1d3e67 GIT binary patch literal 20833 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 b9_+v7zW#+&{u|Z5CGc+v{BI?&JN*9uO%#IC literal 0 HcmV?d00001 diff --git a/ui/public/imgs/workflow/tpl-standard.png b/ui/public/imgs/workflow/tpl-standard.png index 46698a875c3b835a18b4fb9b059d405c905bd181..c14238fff2bf3948763f87a1e111f7b9ae0468f9 100644 GIT binary patch literal 20723 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 AivR!s literal 12858 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¨?LE_}115n{4ET98mJ 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 = new Map([ [WorkflowNodeType.ExecuteResultBranch, i18n.t("workflow_node.execute_result_branch.default_name")], [WorkflowNodeType.ExecuteSuccess, i18n.t("workflow_node.execute_success.default_name")], [WorkflowNodeType.ExecuteFailure, i18n.t("workflow_node.execute_failure.default_name")], - [WorkflowNodeType.Custom, i18n.t("workflow_node.custom.default_name")], ]); const workflowNodeTypeDefaultInputs: Map = new Map([ @@ -240,25 +239,153 @@ const isBranchLike = (node: WorkflowNode) => { }; type InitWorkflowOptions = { - template?: "standard"; + template?: "standard" | "certtest"; }; export const initWorkflow = (options: InitWorkflowOptions = {}): WorkflowModel => { const root = newNode(WorkflowNodeType.Start, {}) as WorkflowNode; root.config = { trigger: WORKFLOW_TRIGGERS.MANUAL }; - if (options.template === "standard") { - let current = root; - current.next = newNode(WorkflowNodeType.Apply, {}); + switch (options.template) { + case "standard": + { + let current = root; - current = current.next; - current.next = newNode(WorkflowNodeType.Deploy, {}); + const applyNode = newNode(WorkflowNodeType.Apply); + current.next = applyNode; - current = current.next; - current.next = newNode(WorkflowNodeType.ExecuteResultBranch, {}); + current = current.next; + current.next = newNode(WorkflowNodeType.ExecuteResultBranch); - current = current.next!.branches![1]; - current.next = newNode(WorkflowNodeType.Notify, {}); + current = current.next!.branches![1]; + current.next = newNode(WorkflowNodeType.Notify, { + nodeConfig: { + subject: "[Certimate] Workflow Failure Alert!", + message: "Your workflow run for the certificate application has failed. Please check the details.", + } as WorkflowNodeConfigForNotify, + }); + + current = applyNode.next!.branches![0]; + current.next = newNode(WorkflowNodeType.Deploy, { + nodeConfig: { + certificate: `${applyNode.id}#certificate`, + skipOnLastSucceeded: true, + } as WorkflowNodeConfigForDeploy, + }); + + current = current.next; + current.next = newNode(WorkflowNodeType.ExecuteResultBranch); + + current = current.next!.branches![1]; + current.next = newNode(WorkflowNodeType.Notify, { + nodeConfig: { + subject: "[Certimate] Workflow Failure Alert!", + message: "Your workflow run for the certificate deployment has failed. Please check the details.", + } as WorkflowNodeConfigForNotify, + }); + } + break; + + case "certtest": + { + let current = root; + + const monitorNode = newNode(WorkflowNodeType.Monitor); + current.next = monitorNode; + + current = current.next; + current.next = newNode(WorkflowNodeType.ExecuteResultBranch); + + current = current.next!.branches![1]; + current.next = newNode(WorkflowNodeType.Notify, { + nodeConfig: { + subject: "[Certimate] Workflow Failure Alert!", + message: "Your workflow run for the certificate monitoring has failed. Please check the details.", + } as WorkflowNodeConfigForNotify, + }); + + current = monitorNode.next!.branches![0]; + const branchNode = newNode(WorkflowNodeType.Branch); + current.next = branchNode; + + current = branchNode.branches![0]; + current.name = i18n.t("workflow_node.condition.default_name.template_certtest_on_expire_soon"); + current.config = { + expression: { + left: { + left: { + selector: { + id: monitorNode.id, + name: "certificate.validity", + type: "boolean", + }, + type: "var", + }, + operator: "eq", + right: { + type: "const", + value: "true", + valueType: "boolean", + }, + type: "comparison", + }, + operator: "and", + right: { + left: { + selector: { + id: monitorNode.id, + name: "certificate.daysLeft", + type: "number", + }, + type: "var", + }, + operator: "lte", + right: { + type: "const", + value: "30", + valueType: "number", + }, + type: "comparison", + }, + type: "logical", + }, + } as WorkflowNodeConfigForCondition; + current.next = newNode(WorkflowNodeType.Notify, { + nodeConfig: { + subject: "[Certimate] Certificate Expiry Alert!", + message: "The certificate will expire soon. Please pay attention to your website.", + } as WorkflowNodeConfigForNotify, + }); + + current = branchNode.branches![1]; + current.name = i18n.t("workflow_node.condition.default_name.template_certtest_on_expired"); + current.config = { + expression: { + left: { + selector: { + id: monitorNode.id, + name: "certificate.validity", + type: "boolean", + }, + type: "var", + }, + operator: "eq", + right: { + type: "const", + value: "false", + valueType: "boolean", + }, + type: "comparison", + }, + } as WorkflowNodeConfigForCondition; + current.next = newNode(WorkflowNodeType.Notify, { + nodeConfig: { + subject: "[Certimate] Certificate Expiry Alert!", + message: "The certificate has already expired. Please pay attention to your website.", + } as WorkflowNodeConfigForNotify, + }); + } + break; } return { @@ -275,6 +402,8 @@ export const initWorkflow = (options: InitWorkflowOptions = {}): WorkflowModel = }; type NewNodeOptions = { + nodeName?: string; + nodeConfig?: Record; branchIndex?: number; }; @@ -284,8 +413,9 @@ export const newNode = (nodeType: WorkflowNodeType, options: NewNodeOptions = {} const node: WorkflowNode = { id: nanoid(), - name: nodeName, + name: options.nodeName ?? nodeName, type: nodeType, + config: options.nodeConfig, }; switch (nodeType) { diff --git a/ui/src/domain/workflowExpr.ts b/ui/src/domain/workflowExpr.ts deleted file mode 100644 index 5f282702..00000000 --- a/ui/src/domain/workflowExpr.ts +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/ui/src/i18n/locales/en/nls.workflow.json b/ui/src/i18n/locales/en/nls.workflow.json index b4f9d7e6..b086e25f 100644 --- a/ui/src/i18n/locales/en/nls.workflow.json +++ b/ui/src/i18n/locales/en/nls.workflow.json @@ -30,6 +30,8 @@ "workflow.new.templates.title": "Choose a Workflow Template", "workflow.new.templates.template.standard.title": "Standard template", "workflow.new.templates.template.standard.description": "A standard operating procedure that includes application, deployment, and notification steps.", + "workflow.new.templates.template.certtest.title": "Monitoring template", + "workflow.new.templates.template.certtest.description": "A monitoring operating procedure that includes monitoring, and notification steps.", "workflow.new.templates.template.blank.title": "Blank template", "workflow.new.templates.template.blank.description": "Customize all the contents of the workflow from the beginning.", "workflow.new.modal.title": "Create workflow", diff --git a/ui/src/i18n/locales/en/nls.workflow.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json index 626e9b68..a22cd8c4 100644 --- a/ui/src/i18n/locales/en/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/en/nls.workflow.nodes.json @@ -876,6 +876,8 @@ "workflow_node.condition.label": "Branch", "workflow_node.condition.default_name": "Branch", + "workflow_node.condition.default_name.template_certtest_on_expire_soon": "If the certificate will expire soon ...", + "workflow_node.condition.default_name.template_certtest_on_expired": "If the certificate has expired ...", "workflow_node.condition.form.expression.label": "Conditions to enter the branch", "workflow_node.condition.form.expression.logical_operator.errmsg": "Please select logical operator of conditions", "workflow_node.condition.form.expression.logical_operator.option.and.label": "Meeting all of the conditions (AND)", diff --git a/ui/src/i18n/locales/zh/nls.workflow.json b/ui/src/i18n/locales/zh/nls.workflow.json index 46cdc228..9ff12aac 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.json +++ b/ui/src/i18n/locales/zh/nls.workflow.json @@ -29,7 +29,9 @@ "workflow.new.subtitle": "使用工作流来申请证书、部署上传和发送通知", "workflow.new.templates.title": "选择工作流模板", "workflow.new.templates.template.standard.title": "标准模板", - "workflow.new.templates.template.standard.description": "一个包含申请 + 部署 + 通知步骤的标准工作流程。", + "workflow.new.templates.template.standard.description": "一个包含证书申请 + 证书部署 + 消息通知步骤的工作流程。", + "workflow.new.templates.template.certtest.title": "监控模板", + "workflow.new.templates.template.certtest.description": "一个包含证书监控 + 消息通知步骤的工作流程。", "workflow.new.templates.template.blank.title": "空白模板", "workflow.new.templates.template.blank.description": "从零开始自定义工作流的任务内容。", "workflow.new.modal.title": "新建工作流", diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index 7710e386..b568d2ca 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -875,6 +875,8 @@ "workflow_node.condition.label": "分支", "workflow_node.condition.default_name": "分支", + "workflow_node.condition.default_name.template_certtest_on_expire_soon": "若网站证书即将到期 ...", + "workflow_node.condition.default_name.template_certtest_on_expired": "若网站证书已到期 ...", "workflow_node.condition.form.expression.label": "分支进入条件", "workflow_node.condition.form.expression.logical_operator.errmsg": "请选择条件组合方式", "workflow_node.condition.form.expression.logical_operator.option.and.label": "满足以下所有条件 (AND)", @@ -900,9 +902,9 @@ "workflow_node.execute_result_branch.label": "执行结果分支", "workflow_node.execute_result_branch.default_name": "执行结果分支", - "workflow_node.execute_success.label": "若前序节点执行成功…", - "workflow_node.execute_success.default_name": "若前序节点执行成功…", + "workflow_node.execute_success.label": "若上一节点执行成功…", + "workflow_node.execute_success.default_name": "若上一节点执行成功…", - "workflow_node.execute_failure.label": "若前序节点执行失败…", - "workflow_node.execute_failure.default_name": "若前序节点执行失败…" + "workflow_node.execute_failure.label": "若上一节点执行失败…", + "workflow_node.execute_failure.default_name": "若上一节点执行失败…" } diff --git a/ui/src/pages/workflows/WorkflowNew.tsx b/ui/src/pages/workflows/WorkflowNew.tsx index 5f6af27b..9877dcc2 100644 --- a/ui/src/pages/workflows/WorkflowNew.tsx +++ b/ui/src/pages/workflows/WorkflowNew.tsx @@ -12,9 +12,10 @@ import { useAntdForm } from "@/hooks"; import { save as saveWorkflow } from "@/repository/workflow"; import { getErrMsg } from "@/utils/error"; -const TEMPLATE_KEY_BLANK = "blank" as const; const TEMPLATE_KEY_STANDARD = "standard" as const; -type TemplateKeys = typeof TEMPLATE_KEY_BLANK | typeof TEMPLATE_KEY_STANDARD; +const TEMPLATE_KEY_CERTTEST = "monitor" as const; +const TEMPLATE_KEY_BLANK = "blank" as const; +type TemplateKeys = typeof TEMPLATE_KEY_BLANK | typeof TEMPLATE_KEY_CERTTEST | typeof TEMPLATE_KEY_STANDARD; const WorkflowNew = () => { const navigate = useNavigate(); @@ -27,8 +28,8 @@ const WorkflowNew = () => { xs: { flex: "100%" }, md: { flex: "100%" }, lg: { flex: "50%" }, - xl: { flex: "50%" }, - xxl: { flex: "50%" }, + xl: { flex: "33.3333%" }, + xxl: { flex: "33.3333%" }, }; const [templateSelectKey, setTemplateSelectKey] = useState(); @@ -64,6 +65,10 @@ const WorkflowNew = () => { workflow = initWorkflow({ template: "standard" }); break; + case TEMPLATE_KEY_CERTTEST: + workflow = initWorkflow({ template: "certtest" }); + break; + default: throw "Invalid state: `templateSelectKey`"; } @@ -116,7 +121,7 @@ const WorkflowNew = () => {
-
+
{t("workflow.new.templates.title")}
@@ -139,6 +144,25 @@ const WorkflowNew = () => {
+ + + } + hoverable + onClick={() => handleTemplateClick(TEMPLATE_KEY_CERTTEST)} + > +
+ + +
+
+ +