From f440103fcea9e35281689270cd146fab32c32ea7 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Wed, 13 Aug 2025 20:51:15 +0800 Subject: [PATCH 01/20] fix(ui): lost animation effect when the drawer closes --- ui/src/components/DrawerForm.tsx | 15 +++--- ui/src/components/access/AccessEditDrawer.tsx | 50 ++++++++++++------- .../certificate/CertificateDetailDrawer.tsx | 48 +++++++++++------- .../workflow/WorkflowRunDetailDrawer.tsx | 47 ++++++++++------- .../workflow/designer/NodeDrawer.tsx | 45 ++++++++++------- .../forms/BizApplyNodeConfigDrawer.tsx | 1 + .../forms/BizDeployNodeConfigDrawer.tsx | 1 + .../forms/BizMonitorNodeConfigDrawer.tsx | 1 + .../forms/BizNotifyNodeConfigDrawer.tsx | 1 + .../forms/BizUploadNodeConfigDrawer.tsx | 1 + .../forms/BranchBlockNodeConfigDrawer.tsx | 1 + .../designer/forms/StartNodeConfigDrawer.tsx | 1 + .../workflow/designer/forms/_shared.tsx | 6 ++- ui/src/pages/accesses/AccessList.tsx | 8 ++- ui/src/pages/certificates/CertificateList.tsx | 5 +- ui/src/pages/dashboard/Dashboard.tsx | 5 +- ui/src/pages/workflows/WorkflowDetailRuns.tsx | 7 ++- 17 files changed, 148 insertions(+), 95 deletions(-) diff --git a/ui/src/components/DrawerForm.tsx b/ui/src/components/DrawerForm.tsx index 25fc6cf34..7c2c1a4fe 100644 --- a/ui/src/components/DrawerForm.tsx +++ b/ui/src/components/DrawerForm.tsx @@ -58,7 +58,7 @@ const DrawerForm = = any>({ form: formInst, formPending, formProps, - submit, + submit: submitForm, } = useAntdForm({ form, onSubmit: (values) => { @@ -75,25 +75,22 @@ const DrawerForm = = any>({ const mergedDrawerProps: DrawerProps = { ...drawerProps, closeIcon: false, - afterOpenChange: (open) => { - if (!open && !mergedFormProps.preserve) { - formInst.resetFields(); - } - - drawerProps?.afterOpenChange?.(open); - }, onClose: async (e) => { if (formPending) return; // 关闭 Drawer 时 Promise.reject 阻止关闭 await drawerProps?.onClose?.(e); setOpen(false); + + if (!mergedFormProps.preserve) { + formInst.resetFields(); + } }, }; const handleOkClick = async () => { // 提交表单返回 Promise.reject 时不关闭 Drawer - await submit(); + await submitForm(); setOpen(false); }; diff --git a/ui/src/components/access/AccessEditDrawer.tsx b/ui/src/components/access/AccessEditDrawer.tsx index 08cf1a69b..c091f3144 100644 --- a/ui/src/components/access/AccessEditDrawer.tsx +++ b/ui/src/components/access/AccessEditDrawer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { startTransition, useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { IconX } from "@tabler/icons-react"; import { useControllableValue } from "ahooks"; @@ -12,6 +12,8 @@ import { getErrMsg } from "@/utils/error"; import AccessForm, { type AccessFormInstance, type AccessFormProps } from "./AccessForm"; export interface AccessEditDrawerProps { + afterClose?: () => void; + afterSubmit?: (record: AccessModel) => void; data?: AccessFormProps["initialValues"]; loading?: boolean; mode: AccessFormProps["mode"]; @@ -19,10 +21,9 @@ export interface AccessEditDrawerProps { trigger?: React.ReactNode; usage?: AccessFormProps["usage"]; onOpenChange?: (open: boolean) => void; - afterSubmit?: (record: AccessModel) => void; } -const AccessEditDrawer = ({ mode, data, loading, trigger, usage, afterSubmit, ...props }: AccessEditDrawerProps) => { +const AccessEditDrawer = ({ afterClose, afterSubmit, mode, data, loading, trigger, usage, ...props }: AccessEditDrawerProps) => { const { t } = useTranslation(); const { notification } = App.useApp(); @@ -99,7 +100,8 @@ const AccessEditDrawer = ({ mode, data, loading, trigger, usage, afterSubmit, .. {triggerEl} !open && afterClose?.()} + autoFocus closeIcon={false} destroyOnHidden footer={ @@ -141,29 +143,41 @@ const AccessEditDrawer = ({ mode, data, loading, trigger, usage, afterSubmit, .. ); }; -const useProps = () => { - const [data, setData] = useState(); +const useDrawer = () => { + type DataType = AccessEditDrawerProps["data"]; + const [data, setData] = useState(); const [open, setOpen] = useState(false); - const onOpenChange = (open: boolean) => { + const onOpenChange = useCallback((open: boolean) => { setOpen(open); - - if (!open) { - setData(void 0); - } - }; + }, []); return { - data, - open, - setData, - setOpen, - onOpenChange, + drawerProps: { + afterClose: () => { + startTransition(() => { + if (!open) { + setData(void 0); + } + }); + }, + data, + open, + onOpenChange, + }, + + open: (data: NonNullable) => { + setData(data); + setOpen(true); + }, + close: () => { + setOpen(false); + }, }; }; const _default = Object.assign(AccessEditDrawer, { - useProps, + useDrawer, }); export default _default; diff --git a/ui/src/components/certificate/CertificateDetailDrawer.tsx b/ui/src/components/certificate/CertificateDetailDrawer.tsx index e050d057a..7a48724bc 100644 --- a/ui/src/components/certificate/CertificateDetailDrawer.tsx +++ b/ui/src/components/certificate/CertificateDetailDrawer.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { startTransition, useCallback, useState } from "react"; import { IconX } from "@tabler/icons-react"; import { useControllableValue } from "ahooks"; import { Button, Drawer, Flex } from "antd"; @@ -9,6 +9,7 @@ import { useTriggerElement } from "@/hooks"; import CertificateDetail from "./CertificateDetail"; export interface CertificateDetailDrawerProps { + afterClose?: () => void; data?: CertificateModel; loading?: boolean; open?: boolean; @@ -16,7 +17,7 @@ export interface CertificateDetailDrawerProps { onOpenChange?: (open: boolean) => void; } -const CertificateDetailDrawer = ({ data, loading, trigger, ...props }: CertificateDetailDrawerProps) => { +const CertificateDetailDrawer = ({ afterClose, data, loading, trigger, ...props }: CertificateDetailDrawerProps) => { const [open, setOpen] = useControllableValue(props, { valuePropName: "open", defaultValuePropName: "defaultOpen", @@ -30,7 +31,8 @@ const CertificateDetailDrawer = ({ data, loading, trigger, ...props }: Certifica {triggerEl} !open && afterClose?.()} + autoFocus closeIcon={false} destroyOnHidden open={open} @@ -60,29 +62,41 @@ const CertificateDetailDrawer = ({ data, loading, trigger, ...props }: Certifica ); }; -const useProps = () => { - const [data, setData] = useState(); +const useDrawer = () => { + type DataType = CertificateDetailDrawerProps["data"]; + const [data, setData] = useState(); const [open, setOpen] = useState(false); - const onOpenChange = (open: boolean) => { + const onOpenChange = useCallback((open: boolean) => { setOpen(open); - - if (!open) { - setData(void 0); - } - }; + }, []); return { - data, - open, - setData, - setOpen, - onOpenChange, + drawerProps: { + afterClose: () => { + startTransition(() => { + if (!open) { + setData(void 0); + } + }); + }, + data, + open, + onOpenChange, + }, + + open: (data: NonNullable) => { + setData(data); + setOpen(true); + }, + close: () => { + setOpen(false); + }, }; }; const _default = Object.assign(CertificateDetailDrawer, { - useProps, + useDrawer, }); export default _default; diff --git a/ui/src/components/workflow/WorkflowRunDetailDrawer.tsx b/ui/src/components/workflow/WorkflowRunDetailDrawer.tsx index 67982b1d6..4419062cc 100644 --- a/ui/src/components/workflow/WorkflowRunDetailDrawer.tsx +++ b/ui/src/components/workflow/WorkflowRunDetailDrawer.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { startTransition, useCallback, useState } from "react"; import { IconX } from "@tabler/icons-react"; import { useControllableValue } from "ahooks"; import { Button, Drawer, Flex } from "antd"; @@ -10,6 +10,7 @@ import { useTriggerElement } from "@/hooks"; import WorkflowRunDetail from "./WorkflowRunDetail"; export interface WorkflowRunDetailDrawerProps { + afterClose?: () => void; data?: WorkflowRunModel; loading?: boolean; open?: boolean; @@ -17,7 +18,7 @@ export interface WorkflowRunDetailDrawerProps { onOpenChange?: (open: boolean) => void; } -const WorkflowRunDetailDrawer = ({ data, loading, trigger, ...props }: WorkflowRunDetailDrawerProps) => { +const WorkflowRunDetailDrawer = ({ afterClose, data, loading, trigger, ...props }: WorkflowRunDetailDrawerProps) => { const [open, setOpen] = useControllableValue(props, { valuePropName: "open", defaultValuePropName: "defaultOpen", @@ -31,7 +32,7 @@ const WorkflowRunDetailDrawer = ({ data, loading, trigger, ...props }: WorkflowR {triggerEl} !open && afterClose?.()} closeIcon={false} destroyOnHidden open={open} @@ -61,29 +62,41 @@ const WorkflowRunDetailDrawer = ({ data, loading, trigger, ...props }: WorkflowR ); }; -const useProps = () => { - const [data, setData] = useState(); +const useDrawer = () => { + type DataType = WorkflowRunDetailDrawerProps["data"]; + const [data, setData] = useState(); const [open, setOpen] = useState(false); - const onOpenChange = (open: boolean) => { + const onOpenChange = useCallback((open: boolean) => { setOpen(open); - - if (!open) { - setData(undefined); - } - }; + }, []); return { - data, - open, - setData, - setOpen, - onOpenChange, + drawerProps: { + afterClose: () => { + startTransition(() => { + if (!open) { + setData(void 0); + } + }); + }, + data, + open, + onOpenChange, + }, + + open: (data: NonNullable) => { + setData(data); + setOpen(true); + }, + close: () => { + setOpen(false); + }, }; }; const _default = Object.assign(WorkflowRunDetailDrawer, { - useProps, + useDrawer, }); export default _default; diff --git a/ui/src/components/workflow/designer/NodeDrawer.tsx b/ui/src/components/workflow/designer/NodeDrawer.tsx index 0c6e26dfa..c7e6ade74 100644 --- a/ui/src/components/workflow/designer/NodeDrawer.tsx +++ b/ui/src/components/workflow/designer/NodeDrawer.tsx @@ -1,4 +1,4 @@ -import { startTransition, useMemo, useState } from "react"; +import { startTransition, useCallback, useMemo, useState } from "react"; import { type FlowNodeEntity } from "@flowgram.ai/fixed-layout-editor"; import { useControllableValue } from "ahooks"; @@ -14,6 +14,7 @@ import StartNodeConfigDrawer from "./forms/StartNodeConfigDrawer"; import { NodeType } from "./nodes/typings"; export interface NodeDrawerProps { + afterClose?: () => void; children?: React.ReactNode; loading?: boolean; node?: FlowNodeEntity; @@ -68,31 +69,41 @@ const NodeDrawer = ({ node, trigger, ...props }: NodeDrawerProps) => { ); }; -const useProps = () => { - const [node, setNode] = useState(); +const useDrawer = () => { + type NodeDataType = NodeDrawerProps["node"]; + const [node, setNode] = useState(); const [open, setOpen] = useState(false); - const onOpenChange = (open: boolean) => { + const onOpenChange = useCallback((open: boolean) => { setOpen(open); - - startTransition(() => { - if (!open) { - setNode(void 0); - } - }); - }; + }, []); return { - node, - open, - setNode, - setOpen, - onOpenChange, + drawerProps: { + afterClose: () => { + startTransition(() => { + if (!open) { + setNode(void 0); + } + }); + }, + node, + open, + onOpenChange, + }, + + open: (node: NonNullable) => { + setNode(node); + setOpen(true); + }, + close: () => { + setOpen(false); + }, }; }; const _default = Object.assign(NodeDrawer, { - useProps, + useDrawer, }); export default _default; diff --git a/ui/src/components/workflow/designer/forms/BizApplyNodeConfigDrawer.tsx b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigDrawer.tsx index 6d736aa83..6781808b6 100644 --- a/ui/src/components/workflow/designer/forms/BizApplyNodeConfigDrawer.tsx +++ b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigDrawer.tsx @@ -7,6 +7,7 @@ import BizApplyNodeConfigForm from "./BizApplyNodeConfigForm"; import { NodeType } from "../nodes/typings"; export interface BizApplyNodeConfigDrawerProps { + afterClose?: () => void; loading?: boolean; node: FlowNodeEntity; open?: boolean; diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigDrawer.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigDrawer.tsx index 8e43fbc3a..bab398d4c 100644 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigDrawer.tsx +++ b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigDrawer.tsx @@ -7,6 +7,7 @@ import BizDeployNodeConfigForm from "./BizDeployNodeConfigForm"; import { NodeType } from "../nodes/typings"; export interface BizDeployNodeConfigDrawerProps { + afterClose?: () => void; loading?: boolean; node: FlowNodeEntity; open?: boolean; diff --git a/ui/src/components/workflow/designer/forms/BizMonitorNodeConfigDrawer.tsx b/ui/src/components/workflow/designer/forms/BizMonitorNodeConfigDrawer.tsx index 3ef0b4cd1..fa7175192 100644 --- a/ui/src/components/workflow/designer/forms/BizMonitorNodeConfigDrawer.tsx +++ b/ui/src/components/workflow/designer/forms/BizMonitorNodeConfigDrawer.tsx @@ -7,6 +7,7 @@ import BizMonitorNodeConfigForm from "./BizMonitorNodeConfigForm"; import { NodeType } from "../nodes/typings"; export interface BizMonitorNodeConfigDrawerProps { + afterClose?: () => void; loading?: boolean; node: FlowNodeEntity; open?: boolean; diff --git a/ui/src/components/workflow/designer/forms/BizNotifyNodeConfigDrawer.tsx b/ui/src/components/workflow/designer/forms/BizNotifyNodeConfigDrawer.tsx index a88c08a77..f5b3e247f 100644 --- a/ui/src/components/workflow/designer/forms/BizNotifyNodeConfigDrawer.tsx +++ b/ui/src/components/workflow/designer/forms/BizNotifyNodeConfigDrawer.tsx @@ -7,6 +7,7 @@ import BizNotifyNodeConfigForm from "./BizNotifyNodeConfigForm"; import { NodeType } from "../nodes/typings"; export interface BizNotifyNodeConfigDrawerProps { + afterClose?: () => void; loading?: boolean; node: FlowNodeEntity; open?: boolean; diff --git a/ui/src/components/workflow/designer/forms/BizUploadNodeConfigDrawer.tsx b/ui/src/components/workflow/designer/forms/BizUploadNodeConfigDrawer.tsx index 826024b8f..2f571a767 100644 --- a/ui/src/components/workflow/designer/forms/BizUploadNodeConfigDrawer.tsx +++ b/ui/src/components/workflow/designer/forms/BizUploadNodeConfigDrawer.tsx @@ -7,6 +7,7 @@ import BizUploadNodeConfigForm from "./BizUploadNodeConfigForm"; import { NodeType } from "../nodes/typings"; export interface BizUploadNodeConfigDrawerProps { + afterClose?: () => void; loading?: boolean; node: FlowNodeEntity; open?: boolean; diff --git a/ui/src/components/workflow/designer/forms/BranchBlockNodeConfigDrawer.tsx b/ui/src/components/workflow/designer/forms/BranchBlockNodeConfigDrawer.tsx index 985301014..4ebe79873 100644 --- a/ui/src/components/workflow/designer/forms/BranchBlockNodeConfigDrawer.tsx +++ b/ui/src/components/workflow/designer/forms/BranchBlockNodeConfigDrawer.tsx @@ -7,6 +7,7 @@ import BranchBlockNodeConfigForm from "./BranchBlockNodeConfigForm"; import { NodeType } from "../nodes/typings"; export interface BranchBlockNodeConfigDrawerProps { + afterClose?: () => void; loading?: boolean; node: FlowNodeEntity; open?: boolean; diff --git a/ui/src/components/workflow/designer/forms/StartNodeConfigDrawer.tsx b/ui/src/components/workflow/designer/forms/StartNodeConfigDrawer.tsx index 0f02fe6c3..cc7cdd23b 100644 --- a/ui/src/components/workflow/designer/forms/StartNodeConfigDrawer.tsx +++ b/ui/src/components/workflow/designer/forms/StartNodeConfigDrawer.tsx @@ -7,6 +7,7 @@ import StartNodeConfigForm from "./StartNodeConfigForm"; import { NodeType } from "../nodes/typings"; export interface StartNodeConfigDrawerProps { + afterClose?: () => void; loading?: boolean; node: FlowNodeEntity; open?: boolean; diff --git a/ui/src/components/workflow/designer/forms/_shared.tsx b/ui/src/components/workflow/designer/forms/_shared.tsx index 6ca67a85e..7dab502c4 100644 --- a/ui/src/components/workflow/designer/forms/_shared.tsx +++ b/ui/src/components/workflow/designer/forms/_shared.tsx @@ -12,6 +12,7 @@ import { type NodeRegistry } from "../nodes/typings"; export interface NodeConfigDrawerProps { children: React.ReactNode; + afterClose?: () => void; anchor?: Pick, "items"> | false; footer?: boolean; form: FormInstance; @@ -21,7 +22,7 @@ export interface NodeConfigDrawerProps { onOpenChange?: (open: boolean) => void; } -export const NodeConfigDrawer = ({ children, anchor, footer = true, form: formInst, loading, node, ...props }: NodeConfigDrawerProps) => { +export const NodeConfigDrawer = ({ children, afterClose, anchor, footer = true, form: formInst, loading, node, ...props }: NodeConfigDrawerProps) => { const { t } = useTranslation(); const { modal, notification } = App.useApp(); @@ -134,7 +135,8 @@ export const NodeConfigDrawer = ({ children, anchor, footer = true, form: formIn paddingBottom: anchor ? 0 : void 0, }, }} - afterOpenChange={setOpen} + afterOpenChange={(open) => !open && afterClose?.()} + autoFocus closeIcon={false} destroyOnHidden footer={ diff --git a/ui/src/pages/accesses/AccessList.tsx b/ui/src/pages/accesses/AccessList.tsx index 2a28ad758..5fdccdb1d 100644 --- a/ui/src/pages/accesses/AccessList.tsx +++ b/ui/src/pages/accesses/AccessList.tsx @@ -281,19 +281,17 @@ const AccessList = () => { navigate(`/accesses/new?usage=${filters["usage"]}`); }; - const { setData: setDetailRecord, setOpen: setDetailOpen, ...detailDrawerProps } = AccessEditDrawer.useProps(); + const { drawerProps: detailDrawerProps, ...detailDrawer } = AccessEditDrawer.useDrawer(); const [detailMode, setDetailMode] = useState("create"); const handleRecordDetailClick = (access: AccessModel) => { - setDetailRecord(access); setDetailMode("edit"); - setDetailOpen(true); + detailDrawer.open(access); }; const handleRecordDuplicateClick = (access: AccessModel) => { - setDetailRecord({ ...access, id: void 0, name: `${access.name}-copy` }); setDetailMode("create"); - setDetailOpen(true); + detailDrawer.open({ ...access, id: void 0, name: `${access.name}-copy` }); }; const handleRecordDeleteClick = async (access: AccessModel) => { diff --git a/ui/src/pages/certificates/CertificateList.tsx b/ui/src/pages/certificates/CertificateList.tsx index c4a01765b..8f4ee9d06 100644 --- a/ui/src/pages/certificates/CertificateList.tsx +++ b/ui/src/pages/certificates/CertificateList.tsx @@ -285,11 +285,10 @@ const CertificateList = () => { setPageSize(pageSize); }; - const { setData: setDetailRecord, setOpen: setDetailOpen, ...detailDrawerProps } = CertificateDetailDrawer.useProps(); + const { drawerProps: detailDrawerProps, ...detailDrawer } = CertificateDetailDrawer.useDrawer(); const handleRecordDetailClick = (certificate: CertificateModel) => { - setDetailRecord(certificate); - setDetailOpen(true); + detailDrawer.open(certificate); }; const handleRecordDeleteClick = (certificate: CertificateModel) => { diff --git a/ui/src/pages/dashboard/Dashboard.tsx b/ui/src/pages/dashboard/Dashboard.tsx index 426fd48a9..980be604f 100644 --- a/ui/src/pages/dashboard/Dashboard.tsx +++ b/ui/src/pages/dashboard/Dashboard.tsx @@ -329,11 +329,10 @@ const WorkflowRunHistoryTable = ({ className, style }: { className?: string; sty refreshData(); }; - const { setData: setDetailRecord, setOpen: setDetailOpen, ...detailDrawerProps } = WorkflowRunDetailDrawer.useProps(); + const { drawerProps: detailDrawerProps, ...detailDrawer } = WorkflowRunDetailDrawer.useDrawer(); const handleRecordDetailClick = (workflowRun: WorkflowRunModel) => { - setDetailRecord(workflowRun); - setDetailOpen(true); + detailDrawer.open(workflowRun); }; return ( diff --git a/ui/src/pages/workflows/WorkflowDetailRuns.tsx b/ui/src/pages/workflows/WorkflowDetailRuns.tsx index b2a2469c6..730ead466 100644 --- a/ui/src/pages/workflows/WorkflowDetailRuns.tsx +++ b/ui/src/pages/workflows/WorkflowDetailRuns.tsx @@ -236,7 +236,7 @@ const WorkflowDetailRuns = () => { }); if (cb.record.id === detailDrawerProps.data?.id) { - setDetailRecord({ ...detailDrawerProps.data, ...cb.record }); + detailDrawerProps.data = { ...detailDrawerProps.data, ...cb.record }; } if (cb.record.status !== WORKFLOW_RUN_STATUSES.PENDING && cb.record.status !== WORKFLOW_RUN_STATUSES.RUNNING) { @@ -257,11 +257,10 @@ const WorkflowDetailRuns = () => { setPageSize(pageSize); }; - const { setData: setDetailRecord, setOpen: setDetailOpen, ...detailDrawerProps } = WorkflowRunDetailDrawer.useProps(); + const { drawerProps: detailDrawerProps, ...detailDrawer } = WorkflowRunDetailDrawer.useDrawer(); const handleRecordDetailClick = (workflowRun: WorkflowRunModel) => { - setDetailRecord(workflowRun); - setDetailOpen(true); + detailDrawer.open(workflowRun); }; const handleRecordCancelClick = (workflowRun: WorkflowRunModel) => { From c7b88cbcfb2a73cd3a4fb1bd9d3c7b3bfce0e5fa Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Wed, 13 Aug 2025 20:59:21 +0800 Subject: [PATCH 02/20] feat(ui): add onDocumentChange handler in WorkflowDesigner --- .../components/workflow/designer/Designer.tsx | 295 ++++++++++-------- .../workflow/designer/DesignerContext.ts | 4 + .../workflow/designer/NodeRender.tsx | 17 +- .../components/workflow/designer/Toolbar.tsx | 1 + .../workflow/designer/nodes/_shared.tsx | 1 + .../pages/workflows/WorkflowDetailDesign.tsx | 11 +- 6 files changed, 186 insertions(+), 143 deletions(-) diff --git a/ui/src/components/workflow/designer/Designer.tsx b/ui/src/components/workflow/designer/Designer.tsx index bee18f0a7..308cf3a75 100644 --- a/ui/src/components/workflow/designer/Designer.tsx +++ b/ui/src/components/workflow/designer/Designer.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useImperativeHandle, useMemo, useRef } from "react"; +import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from "react"; import { ConstantKeys, EditorRenderer, @@ -27,6 +27,8 @@ export interface DesignerProps { children?: React.ReactNode; initialData?: FlowDocumentJSON; readonly?: boolean; + onDocumentChange?: (ctx: FixedLayoutPluginContext) => void; + onNodeChange?: (ctx: FixedLayoutPluginContext, node: FlowNodeEntity) => void; onNodeClick?: (ctx: FixedLayoutPluginContext, node: FlowNodeEntity) => void; } @@ -35,149 +37,180 @@ export interface DesignerInstance extends FixedLayoutPluginContext { validateAllNodes(): Promise; } -const Designer = forwardRef(({ className, style, children, initialData, readonly, onNodeClick }, ref) => { - const { token: themeToken } = theme.useToken(); +const Designer = forwardRef( + ({ className, style, children, initialData, readonly, onDocumentChange, onNodeChange, onNodeClick }, ref) => { + const { token: themeToken } = theme.useToken(); - const flowgramEditorRef = useRef(null); - const flowgramEditorProps = useMemo( - () => ({ - initialData: initialData, + const rendered = useRef(false); - constants: { - [ConstantKeys.BASE_COLOR]: themeToken.colorBorder, - [ConstantKeys.BASE_ACTIVATED_COLOR]: themeToken.colorPrimary, - [ConstantKeys.NODE_SPACING]: 48, - [ConstantKeys.BRANCH_SPACING]: 48, - }, + const flowgramEditorRef = useRef(null); + const flowgramEditorProps = useMemo( + () => ({ + initialData: initialData, - background: { - backgroundColor: themeToken.colorBgContainer, - dotSize: 0, - }, - - playground: { - autoFocus: true, - autoResize: true, - preventGlobalGesture: true, - }, - - selectBox: { - enable: false, - }, - - scroll: { - enableScrollLimit: true, - }, - - readonly: readonly, - - nodeEngine: { - enable: true, - }, - - variableEngine: { - enable: true, - }, - - materials: { - components: getAllElements(), - renderTexts: { - [FlowTextKey.TRY_START_TEXT]: "Try", - [FlowTextKey.TRY_END_TEXT]: "Then", - [FlowTextKey.CATCH_TEXT]: "Catch", + constants: { + [ConstantKeys.BASE_COLOR]: themeToken.colorBorder, + [ConstantKeys.BASE_ACTIVATED_COLOR]: themeToken.colorPrimary, + [ConstantKeys.NODE_SPACING]: 48, + [ConstantKeys.BRANCH_SPACING]: 48, }, - renderDefaultNode: NodeRender, - }, - nodeRegistries: getAllNodeRegistries(), + background: { + backgroundColor: themeToken.colorBgContainer, + dotSize: 0, + }, - getNodeDefaultRegistry(type) { - return { - type, - meta: { - defaultExpanded: true, + playground: { + autoFocus: true, + autoResize: true, + preventGlobalGesture: true, + }, + + selectBox: { + enable: false, + }, + + scroll: { + enableScrollLimit: true, + }, + + readonly: readonly, + + nodeEngine: { + enable: true, + }, + + variableEngine: { + enable: true, + }, + + materials: { + components: getAllElements(), + renderTexts: { + [FlowTextKey.TRY_START_TEXT]: "Try", + [FlowTextKey.TRY_END_TEXT]: "Then", + [FlowTextKey.CATCH_TEXT]: "Catch", }, - formMeta: { - render: () => , - }, - }; - }, + renderDefaultNode: NodeRender, + }, - plugins: () => [ - createMinimapPlugin({ - disableLayer: true, - enableDisplayAllNodes: true, - canvasStyle: { - canvasWidth: 160, - canvasHeight: 160, - }, - }), - ], + nodeRegistries: getAllNodeRegistries(), - onAllLayersRendered: (ctx) => { - // 画布初始化后向下滚动一点,露出可能被 Alert 遮挡的部分 - setTimeout(() => { - ctx.playground.config.scroll({ scrollY: -80 }); - }, 1); - }, - }), - [themeToken, initialData, readonly] - ); + getNodeDefaultRegistry(type) { + return { + type, + meta: { + defaultExpanded: true, + }, + formMeta: { + render: () => , + }, + }; + }, - useImperativeHandle(ref, () => { - return { - get container() { - return flowgramEditorRef.current!.container; - }, - get document() { - return flowgramEditorRef.current!.document; - }, - get playground() { - return flowgramEditorRef.current!.playground; - }, - get operation() { - return flowgramEditorRef.current!.operation; - }, - get clipboard() { - return flowgramEditorRef.current!.clipboard; - }, - get selection() { - return flowgramEditorRef.current!.selection; - }, - get history() { - return flowgramEditorRef.current!.history; - }, + plugins: () => [ + createMinimapPlugin({ + disableLayer: true, + enableDisplayAllNodes: true, + canvasStyle: { + canvasWidth: 160, + canvasHeight: 160, + }, + }), + ], - get(identifier) { - return flowgramEditorRef.current!.get(identifier); - }, - getAll(identifier) { - return flowgramEditorRef.current!.getAll(identifier); - }, - validateNode(node) { - if (typeof node === "string") { - node = flowgramEditorRef.current!.document.getNode(node)!; + onAllLayersRendered: (ctx) => { + rendered.current = true; + + // 画布初始化后向下滚动一点,露出可能被 Alert 遮挡的部分 + setTimeout(() => { + ctx.playground.config.scroll({ scrollY: -80 }); + }, 1); + }, + }), + [themeToken, initialData, readonly] + ); + + useEffect(() => { + const callback = () => { + if (rendered.current) { + onDocumentChange?.(flowgramEditorRef.current!); } + }; + const d1 = flowgramEditorRef.current?.document?.onNodeCreate(callback); + const d2 = flowgramEditorRef.current?.document?.onNodeUpdate(callback); + const d3 = flowgramEditorRef.current?.document?.onNodeDispose(callback); + const d4 = flowgramEditorRef.current?.document?.renderTree?.onTreeChange(callback); - const form = getNodeForm(node); - return form ? form.validate().then((res) => res && !form.state.invalid) : Promise.resolve(true); - }, - validateAllNodes() { - const nodes = flowgramEditorRef.current!.document.getAllNodes(); - const forms = nodes.map((node) => getNodeForm(node)).filter((form) => form != null); - return Promise.allSettled(forms.map((form) => form.validate())).then((res) => forms.every((form, index) => res[index] && !form.state.invalid)); - }, - }; - }); + return () => { + d1?.dispose(); + d2?.dispose(); + d3?.dispose(); + d4?.dispose(); + }; + }, [onDocumentChange]); - return ( - - onNodeClick?.(flowgramEditorRef.current!, node) }}> - - {children} - - - ); -}); + useImperativeHandle(ref, () => { + return { + get container() { + return flowgramEditorRef.current!.container; + }, + get document() { + return flowgramEditorRef.current!.document; + }, + get playground() { + return flowgramEditorRef.current!.playground; + }, + get operation() { + return flowgramEditorRef.current!.operation; + }, + get clipboard() { + return flowgramEditorRef.current!.clipboard; + }, + get selection() { + return flowgramEditorRef.current!.selection; + }, + get history() { + return flowgramEditorRef.current!.history; + }, + + get(identifier) { + return flowgramEditorRef.current!.get(identifier); + }, + getAll(identifier) { + return flowgramEditorRef.current!.getAll(identifier); + }, + validateNode(node) { + if (typeof node === "string") { + node = flowgramEditorRef.current!.document.getNode(node)!; + } + + const form = getNodeForm(node); + return form ? form.validate().then((res) => res && !form.state.invalid) : Promise.resolve(true); + }, + validateAllNodes() { + const nodes = flowgramEditorRef.current!.document.getAllNodes(); + const forms = nodes.map((node) => getNodeForm(node)).filter((form) => form != null); + return Promise.allSettled(forms.map((form) => form.validate())).then((res) => forms.every((form, index) => res[index] && !form.state.invalid)); + }, + }; + }); + + return ( + + onDocumentChange?.(flowgramEditorRef.current!), + onNodeChange: (node) => onNodeChange?.(flowgramEditorRef.current!, node), + onNodeClick: (node) => onNodeClick?.(flowgramEditorRef.current!, node), + }} + > + + {children} + + + ); + } +); export default Designer; diff --git a/ui/src/components/workflow/designer/DesignerContext.ts b/ui/src/components/workflow/designer/DesignerContext.ts index 4db5975eb..b0579a187 100644 --- a/ui/src/components/workflow/designer/DesignerContext.ts +++ b/ui/src/components/workflow/designer/DesignerContext.ts @@ -2,10 +2,14 @@ import { type FlowNodeEntity } from "@flowgram.ai/fixed-layout-editor"; export type DesignerContextType = { + onDocumentChange: () => void; + onNodeChange: (node: FlowNodeEntity) => void; onNodeClick: (node: FlowNodeEntity) => void; }; export const DesignerContext = createContext({ + onDocumentChange: () => {}, + onNodeChange: () => {}, onNodeClick: () => {}, }); diff --git a/ui/src/components/workflow/designer/NodeRender.tsx b/ui/src/components/workflow/designer/NodeRender.tsx index e1553678e..9969aa03b 100644 --- a/ui/src/components/workflow/designer/NodeRender.tsx +++ b/ui/src/components/workflow/designer/NodeRender.tsx @@ -14,16 +14,25 @@ const Node = (_: NodeProps) => { const nodeRender = useNodeRender(); - const designer = useDesignerContext(); + const { onDocumentChange: fireOnDocumentChange, onNodeChange: fireOnNodeChange, onNodeClick: fireOnNodeClick } = useDesignerContext(); useEffect(() => { const d = ctx.document.originTree.onTreeChange(() => refresh()); + return () => d.dispose(); }, []); useEffect(() => { - const d1 = nodeRender.form?.onFormValuesChange?.(() => refresh()); - const d2 = nodeRender.form?.onValidate?.(() => refresh()); + const d1 = nodeRender.form?.onFormValuesChange?.(() => { + refresh(); + + fireOnNodeChange(nodeRender.node); + fireOnDocumentChange(); + }); + const d2 = nodeRender.form?.onValidate?.(() => { + refresh(); + }); + return () => { d1?.dispose(); d2?.dispose(); @@ -33,7 +42,7 @@ const Node = (_: NodeProps) => { const handleNodeClick = () => { const node = nodeRender.node; if (node.getNodeRegistry().meta?.clickable) { - designer.onNodeClick?.(node); + fireOnNodeClick(node); } }; diff --git a/ui/src/components/workflow/designer/Toolbar.tsx b/ui/src/components/workflow/designer/Toolbar.tsx index a7518a5d5..666feeb04 100644 --- a/ui/src/components/workflow/designer/Toolbar.tsx +++ b/ui/src/components/workflow/designer/Toolbar.tsx @@ -25,6 +25,7 @@ const Toolbar = ({ className, style }: ToolbarProps) => { useEffect(() => { const d = playground.config.onReadonlyOrDisabledChange(() => refresh()); + return () => d.dispose(); }, [playground]); diff --git a/ui/src/components/workflow/designer/nodes/_shared.tsx b/ui/src/components/workflow/designer/nodes/_shared.tsx index ac195cb94..d95c0d085 100644 --- a/ui/src/components/workflow/designer/nodes/_shared.tsx +++ b/ui/src/components/workflow/designer/nodes/_shared.tsx @@ -150,6 +150,7 @@ const InternalNodeMenuButton = ({ }; const d1 = node.onEntityChange(callback); const d2 = node.parent?.onEntityChange?.(callback); + return () => { d1?.dispose(); d2?.dispose(); diff --git a/ui/src/pages/workflows/WorkflowDetailDesign.tsx b/ui/src/pages/workflows/WorkflowDetailDesign.tsx index b913e28f9..d0371fb8f 100644 --- a/ui/src/pages/workflows/WorkflowDetailDesign.tsx +++ b/ui/src/pages/workflows/WorkflowDetailDesign.tsx @@ -55,12 +55,7 @@ const WorkflowDetailDesign = () => { console.log("document changed", designerRef.current!.document.toJSON()); }); - useEffect(() => { - const disposable = designerRef.current?.document?.originTree?.onTreeChange(onDesignerDocumentChange); - return () => disposable?.dispose(); - }, []); - - const { setNode: setDrawerNode, setOpen: setNodeDrawerOpen, ...nodeDrawerProps } = WorkflowNodeDrawer.useProps(); + const { drawerProps: nodeDrawerProps, ...nodeDrawer } = WorkflowNodeDrawer.useDrawer(); const handleRollbackClick = () => { modal.confirm({ @@ -116,9 +111,9 @@ const WorkflowDetailDesign = () => { { - setDrawerNode(node); - setNodeDrawerOpen(true); + nodeDrawer.open(node); }} >
From 139746ba62aeb7242e66cee7a2029b87f45dee68 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Thu, 14 Aug 2025 21:51:48 +0800 Subject: [PATCH 03/20] feat(ui): node outputs adapt to new workflow designer --- ui/index.html | 2 +- ui/package-lock.json | 488 +++++++++--------- ui/package.json | 20 +- ui/src/components/CodeInput.tsx | 32 +- .../components/workflow/designer/Designer.tsx | 50 +- .../components/workflow/designer/Toolbar.tsx | 2 +- ui/src/components/workflow/designer/_util.ts | 182 +++++++ .../designer/forms/BizApplyNodeConfigForm.tsx | 6 +- .../forms/BizDeployNodeConfigForm.tsx | 87 ++-- .../forms/BizNotifyNodeConfigForm.tsx | 6 +- ...nchBlockNodeConfigFormExpressionEditor.tsx | 37 +- .../workflow/designer/forms/_shared.tsx | 3 +- ui/src/components/workflow/designer/index.ts | 16 + .../designer/nodes/BizApplyNodeRegistry.tsx | 6 +- .../designer/nodes/BizDeployNodeRegistry.tsx | 16 +- .../designer/nodes/BizMonitorNodeRegistry.tsx | 7 +- .../designer/nodes/BizNotifyNodeRegistry.tsx | 6 +- .../designer/nodes/BizUploadNodeRegistry.tsx | 7 +- .../workflow/designer/nodes/ConditionNode.tsx | 10 +- .../workflow/designer/nodes/EndNode.tsx | 19 +- .../workflow/designer/nodes/TryCatchNode.tsx | 14 +- .../workflow/designer/nodes/_shared.tsx | 92 +--- .../workflow/designer/nodes/index.ts | 2 - ui/src/domain/workflow.ts | 3 +- .../i18n/locales/en/nls.workflow.nodes.json | 9 +- .../i18n/locales/zh/nls.workflow.nodes.json | 9 +- ui/src/pages/dashboard/Dashboard.tsx | 1 - .../pages/workflows/WorkflowDetailDesign.tsx | 20 +- 28 files changed, 660 insertions(+), 492 deletions(-) create mode 100644 ui/src/components/workflow/designer/_util.ts create mode 100644 ui/src/components/workflow/designer/index.ts diff --git a/ui/index.html b/ui/index.html index fe1c7f6b9..ba75b0475 100644 --- a/ui/index.html +++ b/ui/index.html @@ -4,7 +4,7 @@ - Certimate - Your Trusted SSL Automation Partner + Certimate - Your Trusted Partner in SSL Automation
diff --git a/ui/package-lock.json b/ui/package-lock.json index 400857945..0fdacb7c7 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -11,13 +11,13 @@ "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.11.2", "@codemirror/legacy-modes": "^6.5.1", - "@flowgram.ai/document": "^0.3.5", - "@flowgram.ai/fixed-layout-editor": "^0.3.5", - "@flowgram.ai/minimap-plugin": "^0.3.5", + "@flowgram.ai/document": "^0.3.6", + "@flowgram.ai/fixed-layout-editor": "^0.3.6", + "@flowgram.ai/minimap-plugin": "^0.3.6", "@tabler/icons-react": "^3.34.1", - "@uiw/codemirror-extensions-basic-setup": "^4.24.2", - "@uiw/codemirror-theme-vscode": "^4.24.2", - "@uiw/react-codemirror": "^4.24.2", + "@uiw/codemirror-extensions-basic-setup": "^4.25.1", + "@uiw/codemirror-theme-vscode": "^4.25.1", + "@uiw/react-codemirror": "^4.25.1", "ahooks": "^3.9.0", "antd": "^5.27.0", "antd-zod": "^7.0.0", @@ -2408,12 +2408,12 @@ } }, "node_modules/@flowgram.ai/background-plugin": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/background-plugin/-/background-plugin-0.3.5.tgz", - "integrity": "sha512-4iUka9SQNNceNQwivTBE1XPJjNSj4tP/6dieO2CVInPEl/PgIRodgTM5gjVvrnr/Mvrk1231+ttFL68HsVnByA==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/background-plugin/-/background-plugin-0.3.6.tgz", + "integrity": "sha512-nFCbR1JDbuZHPmwlnjxHyP1WeM3xv786GLdKfzU6h5v+VRrHZsRgu3r+j6d+CQSYTM3Mem3UAlXI4BWukZa9UQ==", "dependencies": { - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/utils": "0.3.5", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/utils": "0.3.6", "inversify": "^6.0.1", "reflect-metadata": "~0.2.2" }, @@ -2423,11 +2423,11 @@ } }, "node_modules/@flowgram.ai/command": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/command/-/command-0.3.5.tgz", - "integrity": "sha512-FWGaOD0+vGaKzDq2+c7NtzTyLofK/9rUoXA8YLbEnG+CNSaCDh9Q2+93YrTowRcCvSWxSHewGlLSquj2uAP3Wg==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/command/-/command-0.3.6.tgz", + "integrity": "sha512-tI7192YZnFmC4t298PLd9u+dDwFsCzWaA2p2qVSpiTlGsk2ZOdTOfKbA/sFqearV0DOAG97KW1h81M4XbXGKkQ==", "dependencies": { - "@flowgram.ai/utils": "0.3.5", + "@flowgram.ai/utils": "0.3.6", "inversify": "^6.0.1", "reflect-metadata": "~0.2.2" }, @@ -2437,12 +2437,12 @@ } }, "node_modules/@flowgram.ai/core": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/core/-/core-0.3.5.tgz", - "integrity": "sha512-XrrogUnSjari3FipYUqpyiPKy30vewI5Jp9khRgyXc9cZiLVbdOd8Ki/LzHyxQc0E5R2H84XHHCpt1eWcKGMew==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/core/-/core-0.3.6.tgz", + "integrity": "sha512-/6PeNGAa0lGmCoA7/PzqGx0vnWQ+oizdvKG9P8/sm0aI8iu9+wxGOKUGUNUASkDRzJk8vzIPzP/26qaPrxKK2w==", "dependencies": { - "@flowgram.ai/command": "0.3.5", - "@flowgram.ai/utils": "0.3.5", + "@flowgram.ai/command": "0.3.6", + "@flowgram.ai/utils": "0.3.6", "@phosphor/messaging": "^1.3.0", "@tweenjs/tween.js": "^18", "clsx": "^1.1.1", @@ -2482,12 +2482,12 @@ } }, "node_modules/@flowgram.ai/document": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/document/-/document-0.3.5.tgz", - "integrity": "sha512-Y6Rrka33peD56bfJ5kDm2Ah+aDXiLJZdhepVoyvVBupySDnsGvAl7D4s0/dev+zQb6XsatgWAJf1SZ3UzdeTxQ==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/document/-/document-0.3.6.tgz", + "integrity": "sha512-idEXiFsoOrWgxA1DAQy2QBm+OI14xAZ87L7IjDJO6aaU52VpJxZZuQEQ3DI+ocndzT4kTaJsK2/QyzRPA98H7A==", "dependencies": { - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/utils": "0.3.5", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/utils": "0.3.6", "inversify": "^6.0.1", "lodash": "^4.17.21", "nanoid": "^4.0.2", @@ -2512,29 +2512,29 @@ } }, "node_modules/@flowgram.ai/editor": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/editor/-/editor-0.3.5.tgz", - "integrity": "sha512-/DCivTv4Gog8bXJ/qL10CTAGcwitnP3+Mnb6bJAjwvfX/y5FCD0OL9SKRIVXQhip4zxmFrmZmwnswJPnpbvxtA==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/editor/-/editor-0.3.6.tgz", + "integrity": "sha512-xsKJ0RtJUDlq08KXaFTnjYgvDhg5zU2EaeXPk1vGGgK2dWKrEir7odpGrr3jOxt9oTIaT3Xdxoi9YwneqL3srw==", "dependencies": { - "@flowgram.ai/background-plugin": "0.3.5", - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/document": "0.3.5", - "@flowgram.ai/form": "0.3.5", - "@flowgram.ai/form-core": "0.3.5", - "@flowgram.ai/group-plugin": "0.3.5", - "@flowgram.ai/history": "0.3.5", - "@flowgram.ai/history-node-plugin": "0.3.5", - "@flowgram.ai/i18n-plugin": "0.3.5", - "@flowgram.ai/materials-plugin": "0.3.5", - "@flowgram.ai/node": "0.3.5", - "@flowgram.ai/node-core-plugin": "0.3.5", - "@flowgram.ai/node-variable-plugin": "0.3.5", - "@flowgram.ai/playground-react": "0.3.5", - "@flowgram.ai/redux-devtool-plugin": "0.3.5", - "@flowgram.ai/renderer": "0.3.5", - "@flowgram.ai/shortcuts-plugin": "0.3.5", - "@flowgram.ai/utils": "0.3.5", - "@flowgram.ai/variable-plugin": "0.3.5", + "@flowgram.ai/background-plugin": "0.3.6", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/document": "0.3.6", + "@flowgram.ai/form": "0.3.6", + "@flowgram.ai/form-core": "0.3.6", + "@flowgram.ai/group-plugin": "0.3.6", + "@flowgram.ai/history": "0.3.6", + "@flowgram.ai/history-node-plugin": "0.3.6", + "@flowgram.ai/i18n-plugin": "0.3.6", + "@flowgram.ai/materials-plugin": "0.3.6", + "@flowgram.ai/node": "0.3.6", + "@flowgram.ai/node-core-plugin": "0.3.6", + "@flowgram.ai/node-variable-plugin": "0.3.6", + "@flowgram.ai/playground-react": "0.3.6", + "@flowgram.ai/redux-devtool-plugin": "0.3.6", + "@flowgram.ai/renderer": "0.3.6", + "@flowgram.ai/shortcuts-plugin": "0.3.6", + "@flowgram.ai/utils": "0.3.6", + "@flowgram.ai/variable-plugin": "0.3.6", "inversify": "^6.0.1", "reflect-metadata": "~0.2.2" }, @@ -2544,13 +2544,13 @@ } }, "node_modules/@flowgram.ai/fixed-drag-plugin": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/fixed-drag-plugin/-/fixed-drag-plugin-0.3.5.tgz", - "integrity": "sha512-sytXPYyiNU6RpLKsTPeXd83BWQjQaIrgQRGKuSoTYI9LUe8fDKz558xE/kJaLOvoyfeQwvpKj1kp03LAZ63+zA==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/fixed-drag-plugin/-/fixed-drag-plugin-0.3.6.tgz", + "integrity": "sha512-UhWVMTCKpsVu63M3VCxDjmAST88eNKrfkHNCQ73+obS/dKNKh4tyroz9kSnGHYfWjxme40fUP4bO459QgDhg7Q==", "dependencies": { - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/document": "0.3.5", - "@flowgram.ai/renderer": "0.3.5", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/document": "0.3.6", + "@flowgram.ai/renderer": "0.3.6", "inversify": "^6.0.1", "reflect-metadata": "~0.2.2" }, @@ -2560,29 +2560,29 @@ } }, "node_modules/@flowgram.ai/fixed-history-plugin": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/fixed-history-plugin/-/fixed-history-plugin-0.3.5.tgz", - "integrity": "sha512-V7QTfMykzmVfn0eaq1z7C/ukQnJhP8kr2qGUK/fxjOqhacgcBYW1+Pc+XsQWHQbo5dADr2kTlr42dgqjqY2Grg==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/fixed-history-plugin/-/fixed-history-plugin-0.3.6.tgz", + "integrity": "sha512-bVWoKNUDybC5ojnZ/j1LFGRAfKw/3Ur/JYtA80d2qaq2WT+XXd2+f1RrbpRQaGQvcpVwhZT+oHFINn4srUl5vg==", "dependencies": { - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/document": "0.3.5", - "@flowgram.ai/form-core": "0.3.5", - "@flowgram.ai/history": "0.3.5", - "@flowgram.ai/utils": "0.3.5", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/document": "0.3.6", + "@flowgram.ai/form-core": "0.3.6", + "@flowgram.ai/history": "0.3.6", + "@flowgram.ai/utils": "0.3.6", "inversify": "^6.0.1", "lodash": "^4.17.21", "reflect-metadata": "~0.2.2" } }, "node_modules/@flowgram.ai/fixed-layout-core": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/fixed-layout-core/-/fixed-layout-core-0.3.5.tgz", - "integrity": "sha512-ojhQaIOweR5QH7gsc7fWavb5aF270jHu0m0RIjLtc7dFCUMiadlU+tkm2sP7Y1BM76aPXsG3hnjHUk7wM2sU4A==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/fixed-layout-core/-/fixed-layout-core-0.3.6.tgz", + "integrity": "sha512-TbpDN7zKaZCMk5RfRxTCuQkmd56cC11qFPysMzGe5g/2Yqy3OZ5P8VdavhHrOv/hN9sdnHrs8mEirN1APIp22Q==", "dependencies": { - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/document": "0.3.5", - "@flowgram.ai/renderer": "0.3.5", - "@flowgram.ai/utils": "0.3.5", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/document": "0.3.6", + "@flowgram.ai/renderer": "0.3.6", + "@flowgram.ai/utils": "0.3.6", "inversify": "^6.0.1", "lodash": "^4.17.21", "reflect-metadata": "~0.2.2" @@ -2593,19 +2593,19 @@ } }, "node_modules/@flowgram.ai/fixed-layout-editor": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/fixed-layout-editor/-/fixed-layout-editor-0.3.5.tgz", - "integrity": "sha512-bwMChriT0e0msP/6UGS6gAH+r3wqPDI2PObV01c/+JxkPjTHK/B+e1WoI5xZriQB7WQ0Y0/v2pxxrYmiGP3rvw==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/fixed-layout-editor/-/fixed-layout-editor-0.3.6.tgz", + "integrity": "sha512-0FMBbd6VXsHLFF5kBptEKp6S0YGYdlHNKZGI+KYJL3KwtklqoYr8/AvFtY5IS6nF3n44tP6m8CsuvwQZUXIFAw==", "dependencies": { - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/editor": "0.3.5", - "@flowgram.ai/fixed-drag-plugin": "0.3.5", - "@flowgram.ai/fixed-history-plugin": "0.3.5", - "@flowgram.ai/fixed-layout-core": "0.3.5", - "@flowgram.ai/history": "0.3.5", - "@flowgram.ai/reactive": "0.3.5", - "@flowgram.ai/select-box-plugin": "0.3.5", - "@flowgram.ai/utils": "0.3.5", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/editor": "0.3.6", + "@flowgram.ai/fixed-drag-plugin": "0.3.6", + "@flowgram.ai/fixed-history-plugin": "0.3.6", + "@flowgram.ai/fixed-layout-core": "0.3.6", + "@flowgram.ai/history": "0.3.6", + "@flowgram.ai/reactive": "0.3.6", + "@flowgram.ai/select-box-plugin": "0.3.6", + "@flowgram.ai/utils": "0.3.6", "inversify": "^6.0.1", "reflect-metadata": "~0.2.2" }, @@ -2615,12 +2615,12 @@ } }, "node_modules/@flowgram.ai/form": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/form/-/form-0.3.5.tgz", - "integrity": "sha512-PmoG4k+aAdzt0v2aZKb3A/ArpUe0NKsACU68185PyXfGl8AU+GWP8ujdx3txKm2VTVEDjOFWsLSMh1/3RkRgkA==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/form/-/form-0.3.6.tgz", + "integrity": "sha512-lXNCq8sgNOPc50uVbPHAk+IsSFFNIvLkMMVvGaXXapH9p4Up5zGVPrRRuzCKeUdZHMe7wvWiAvR5hG335EsCKQ==", "dependencies": { - "@flowgram.ai/reactive": "0.3.5", - "@flowgram.ai/utils": "0.3.5", + "@flowgram.ai/reactive": "0.3.6", + "@flowgram.ai/utils": "0.3.6", "fast-equals": "^2.0.0", "lodash": "^4.17.21", "nanoid": "^4.0.2" @@ -2631,13 +2631,13 @@ } }, "node_modules/@flowgram.ai/form-core": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/form-core/-/form-core-0.3.5.tgz", - "integrity": "sha512-fgzjJT9cUIYcclRSbMfoAHS9hqN8AvbvaZcx2EXlQDVpGAZsrTrMe4WmPe1mQ3eCakj9oj4VKqu8kipfT6spSg==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/form-core/-/form-core-0.3.6.tgz", + "integrity": "sha512-wVrJZ+o1FbLwLBOchbJ1vc8BYPeMqHUuGNUu4TdmNvmHQtxChwUqX2nyCQYUXP3/sXnQnpuKHfvwA6VY12H6gQ==", "dependencies": { - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/document": "0.3.5", - "@flowgram.ai/utils": "0.3.5", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/document": "0.3.6", + "@flowgram.ai/utils": "0.3.6", "inversify": "^6.0.1", "lodash": "^4.17.21", "reflect-metadata": "~0.2.2" @@ -2665,16 +2665,16 @@ } }, "node_modules/@flowgram.ai/free-layout-core": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/free-layout-core/-/free-layout-core-0.3.5.tgz", - "integrity": "sha512-ZyjSFe+/Wg8wYXtxylMfLTuapOwSucHIXmJXD4Oz6WJQk39mJ6Cfds8pbp9aPDpFYfhRdQJOLAb9nUOh5+QnBw==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/free-layout-core/-/free-layout-core-0.3.6.tgz", + "integrity": "sha512-Rcaol6+X1cBLX6Iu3AwixZC7516fOocxMPW2JeSPrWJChDOJmHtme5iMcxAKirpct9L/+AxSD0c0gIsrEmiqag==", "dependencies": { - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/document": "0.3.5", - "@flowgram.ai/form-core": "0.3.5", - "@flowgram.ai/node": "0.3.5", - "@flowgram.ai/reactive": "0.3.5", - "@flowgram.ai/utils": "0.3.5", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/document": "0.3.6", + "@flowgram.ai/form-core": "0.3.6", + "@flowgram.ai/node": "0.3.6", + "@flowgram.ai/reactive": "0.3.6", + "@flowgram.ai/utils": "0.3.6", "inversify": "^6.0.1", "lodash-es": "^4.17.21", "nanoid": "^4.0.2", @@ -2703,14 +2703,14 @@ } }, "node_modules/@flowgram.ai/group-plugin": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/group-plugin/-/group-plugin-0.3.5.tgz", - "integrity": "sha512-FRic+LX9NjjW18sd7A/lV1v+wBJKwDnFUranye1nK2f9znN51k1GLm/0W/Ch0N0pZXlV8clCHYaMN9TIIZMHtQ==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/group-plugin/-/group-plugin-0.3.6.tgz", + "integrity": "sha512-neDrH5KNPStAmUgQhEuTmfd0zFgsERFplRIwXX+ByV7gPpHWdPHoboTPSzJ7ZXEAjogFEQgY8IRsp7KSA7iNAw==", "dependencies": { - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/document": "0.3.5", - "@flowgram.ai/renderer": "0.3.5", - "@flowgram.ai/utils": "0.3.5", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/document": "0.3.6", + "@flowgram.ai/renderer": "0.3.6", + "@flowgram.ai/utils": "0.3.6", "inversify": "^6.0.1", "reflect-metadata": "~0.2.2" }, @@ -2720,12 +2720,12 @@ } }, "node_modules/@flowgram.ai/history": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/history/-/history-0.3.5.tgz", - "integrity": "sha512-G0vU9vNJdpbHSnBx8GG8VOyTfD5Qy4L4j8S577SHl3RxhIIh7vr1tm5REEzzhxJWsvOID+v1+73XhSTzTmVi8w==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/history/-/history-0.3.6.tgz", + "integrity": "sha512-Hk3u5lLh29XqZovyrzPI35ItgB4u0+J1N36u6+HlR+Wk82HTHW6K4sKQErzZFSVEByBC/X1XlwtSNOOutxQALw==", "dependencies": { - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/utils": "0.3.5", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/utils": "0.3.6", "inversify": "^6.0.1", "lodash": "^4.17.21", "nanoid": "^4.0.2", @@ -2733,17 +2733,17 @@ } }, "node_modules/@flowgram.ai/history-node-plugin": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/history-node-plugin/-/history-node-plugin-0.3.5.tgz", - "integrity": "sha512-oeoy0DJdP0dAfcOLRnKPB/r6wJcnd2rtkVwVrrpQCr3H2QF7IBNKmQFf+ueXR6e8onbNoS0b7stYqCRIFeeCaQ==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/history-node-plugin/-/history-node-plugin-0.3.6.tgz", + "integrity": "sha512-PD7w6/VBExL4VQkWwh+x7OjnPZaGTPhXB1DSETivTtj8pfB4HkihbtG5VlnyoDJWooBvzBJhk2Dmx91fGak6Tw==", "dependencies": { - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/document": "0.3.5", - "@flowgram.ai/form": "0.3.5", - "@flowgram.ai/form-core": "0.3.5", - "@flowgram.ai/history": "0.3.5", - "@flowgram.ai/node": "0.3.5", - "@flowgram.ai/utils": "0.3.5", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/document": "0.3.6", + "@flowgram.ai/form": "0.3.6", + "@flowgram.ai/form-core": "0.3.6", + "@flowgram.ai/history": "0.3.6", + "@flowgram.ai/node": "0.3.6", + "@flowgram.ai/utils": "0.3.6", "inversify": "^6.0.1", "lodash": "^4.17.21", "reflect-metadata": "~0.2.2" @@ -2772,32 +2772,32 @@ } }, "node_modules/@flowgram.ai/i18n": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/i18n/-/i18n-0.3.5.tgz", - "integrity": "sha512-5G4wMZ3QjCIEeU2g0Cqa+vjvEoVZxs/hFx/9AV1HusBBDjeV3LOod8AM50VwIDbL/nGjLHQo2e7Nf6WBMOj86Q==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/i18n/-/i18n-0.3.6.tgz", + "integrity": "sha512-yPuYl1miztb4I4tC1QFzvYyN82j+qWIsxcahizbmrI9eA3RhsqTDq/UgX7r9pOzVlfdITMqQ8OKUyW/lPNZcjA==", "dependencies": { - "@flowgram.ai/utils": "0.3.5", + "@flowgram.ai/utils": "0.3.6", "i18n-js": "^4.5.1" } }, "node_modules/@flowgram.ai/i18n-plugin": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/i18n-plugin/-/i18n-plugin-0.3.5.tgz", - "integrity": "sha512-rm+nQ5r7QAWSEjEyO+cT9raIVOGDa5QIdezI+NiCco27VFGsRY28dQXROd0jf/mDK91IfHrSec5nVks5bE9ZMg==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/i18n-plugin/-/i18n-plugin-0.3.6.tgz", + "integrity": "sha512-2FJACeapk49F/kpfVNI9DwYPQITvXYfA86pAWKPL51v1TncBdbdvzc3WSSOEXVdWpvA8axztYlrwYQz8ra9tvA==", "dependencies": { - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/i18n": "0.3.5" + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/i18n": "0.3.6" } }, "node_modules/@flowgram.ai/materials-plugin": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/materials-plugin/-/materials-plugin-0.3.5.tgz", - "integrity": "sha512-ZORaEEDBPKXbTD9LIhPX8PfPfeIYiyJsPkwKm+wtSEoKbP7G+tahSMTVPDwLiIeWbQ93lEZK1/JK2XpL6rA19Q==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/materials-plugin/-/materials-plugin-0.3.6.tgz", + "integrity": "sha512-UVyPEMnzP14bULUIpThogzaJA3Xc0Uo5QVTwB8nZ4gpo4tlkv2ssLbAUnJx6fB/+1Fh99vRdJb1lR8mbfsauOA==", "dependencies": { - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/form-core": "0.3.5", - "@flowgram.ai/renderer": "0.3.5", - "@flowgram.ai/utils": "0.3.5", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/form-core": "0.3.6", + "@flowgram.ai/renderer": "0.3.6", + "@flowgram.ai/utils": "0.3.6", "inversify": "^6.0.1", "reflect-metadata": "~0.2.2" }, @@ -2808,14 +2808,14 @@ } }, "node_modules/@flowgram.ai/minimap-plugin": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/minimap-plugin/-/minimap-plugin-0.3.5.tgz", - "integrity": "sha512-+mQx4SoaoA0EghPRmBxqIK5jbHQrzswfg9CQ3kqk5Yu5rhPTpmr+IaHbRK6FWpuehJZNxkjcUKAY705rtFIZcA==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/minimap-plugin/-/minimap-plugin-0.3.6.tgz", + "integrity": "sha512-f3sDist0dOp3hHDTp83e5Y8m+9c/vKnm3emdGXeomZZgDqCMTCYC+4OPHLCxNLhdxdaLbV5EAvRY7mVHk63qIw==", "dependencies": { - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/document": "0.3.5", - "@flowgram.ai/renderer": "0.3.5", - "@flowgram.ai/utils": "0.3.5", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/document": "0.3.6", + "@flowgram.ai/renderer": "0.3.6", + "@flowgram.ai/utils": "0.3.6", "inversify": "^6.0.1", "lodash": "^4.17.21", "reflect-metadata": "~0.2.2" @@ -2827,15 +2827,15 @@ } }, "node_modules/@flowgram.ai/node": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/node/-/node-0.3.5.tgz", - "integrity": "sha512-OQvcuMH3T+l9RCXfAt4tWX6tijXtCp/Kkp/pcoNFfJ7o1abfRHMKEE66i51TPn89MYrIjRxH0GY+qcm9UOuiQQ==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/node/-/node-0.3.6.tgz", + "integrity": "sha512-Wg7lbw/MAezapLuYCfJ/m89f/RbFwntpoWuR9B9/Eah2riGTQOYpU69+9IuRjokpaqW4T/KNDFF6BZiOz0h+Ew==", "dependencies": { - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/document": "0.3.5", - "@flowgram.ai/form": "0.3.5", - "@flowgram.ai/form-core": "0.3.5", - "@flowgram.ai/utils": "0.3.5", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/document": "0.3.6", + "@flowgram.ai/form": "0.3.6", + "@flowgram.ai/form-core": "0.3.6", + "@flowgram.ai/utils": "0.3.6", "inversify": "^6.0.1", "lodash": "^4.17.21", "nanoid": "^4.0.2", @@ -2847,16 +2847,16 @@ } }, "node_modules/@flowgram.ai/node-core-plugin": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/node-core-plugin/-/node-core-plugin-0.3.5.tgz", - "integrity": "sha512-SdWDx2ghcRbb6imacoAi+j/tZIVPcLt7FGYBg0FpXH2r0jVioXjq1BRq11GpvQqlCyDuE/J+IP21NWi1DSwKcA==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/node-core-plugin/-/node-core-plugin-0.3.6.tgz", + "integrity": "sha512-5KIJaFb6CmZbXosN+D5ulNLRiDFJEq+DK3jbCqw+Hg+BC1YA2EAwCVTQvphptYAXP12BQ8B23S0QMYGUkuhWsA==", "dependencies": { - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/document": "0.3.5", - "@flowgram.ai/form-core": "0.3.5", - "@flowgram.ai/node": "0.3.5", - "@flowgram.ai/renderer": "0.3.5", - "@flowgram.ai/utils": "0.3.5", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/document": "0.3.6", + "@flowgram.ai/form-core": "0.3.6", + "@flowgram.ai/node": "0.3.6", + "@flowgram.ai/renderer": "0.3.6", + "@flowgram.ai/utils": "0.3.6", "inversify": "^6.0.1", "reflect-metadata": "~0.2.2" }, @@ -2867,16 +2867,16 @@ } }, "node_modules/@flowgram.ai/node-variable-plugin": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/node-variable-plugin/-/node-variable-plugin-0.3.5.tgz", - "integrity": "sha512-uZXyuBQDC9GHnJsx8w+aVM3Eb9+DNNH0qe1rZzS5MrrRG7A85t/OsGPL98p2kZJVU6Q6N+38REchsV9KdYot+Q==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/node-variable-plugin/-/node-variable-plugin-0.3.6.tgz", + "integrity": "sha512-7lBgAOyfGqNCSSVWqe3QetNG4hK9UiABz0DCVZl2ihc/9TuTev+BS+Lb0w/aTNKsr4HvTXbeERsxyTdolKrYVA==", "dependencies": { - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/document": "0.3.5", - "@flowgram.ai/form-core": "0.3.5", - "@flowgram.ai/node": "0.3.5", - "@flowgram.ai/utils": "0.3.5", - "@flowgram.ai/variable-plugin": "0.3.5", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/document": "0.3.6", + "@flowgram.ai/form-core": "0.3.6", + "@flowgram.ai/node": "0.3.6", + "@flowgram.ai/utils": "0.3.6", + "@flowgram.ai/variable-plugin": "0.3.6", "inversify": "^6.0.1", "lodash": "^4.17.21", "reflect-metadata": "~0.2.2" @@ -2905,14 +2905,14 @@ } }, "node_modules/@flowgram.ai/playground-react": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/playground-react/-/playground-react-0.3.5.tgz", - "integrity": "sha512-zBcHDS7hQ58F4o3yw0eGNv3U2qcp2qvdRJmHD0oyyCCfygBk1iqycqB8mK0J0CaferPIr7xf7CW4QAsJ/EAvbQ==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/playground-react/-/playground-react-0.3.6.tgz", + "integrity": "sha512-cL4i0sg3h9Y693N5i1tLrjleJsM9wMC+E+bNR7pV2BatRji7boz3+sfBfOIZnMCAxgl4ppUtnXGt2BYnkl2Pkw==", "dependencies": { - "@flowgram.ai/background-plugin": "0.3.5", - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/shortcuts-plugin": "0.3.5", - "@flowgram.ai/utils": "0.3.5", + "@flowgram.ai/background-plugin": "0.3.6", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/shortcuts-plugin": "0.3.6", + "@flowgram.ai/utils": "0.3.6", "inversify": "^6.0.1", "reflect-metadata": "~0.2.2" }, @@ -2922,11 +2922,11 @@ } }, "node_modules/@flowgram.ai/reactive": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/reactive/-/reactive-0.3.5.tgz", - "integrity": "sha512-qzb7bB7SXk2JkikaCRxzKAs5rvSJqAfCCSwgbXSo3/1r4XsARuGYE0iyoobl0Fa4gD9kUk1FSv7htsxTqja9SQ==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/reactive/-/reactive-0.3.6.tgz", + "integrity": "sha512-iv0eWuZkVo1vHRO6f+nFGlZCDiRNSN4+cOtZb40RepKVU8vNZr9xLptsnZ1vvh8csl7LLi5V/7V/ZaZ2BqGskw==", "dependencies": { - "@flowgram.ai/utils": "0.3.5" + "@flowgram.ai/utils": "0.3.6" }, "peerDependencies": { "react": ">=16.8", @@ -2934,12 +2934,12 @@ } }, "node_modules/@flowgram.ai/redux-devtool-plugin": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/redux-devtool-plugin/-/redux-devtool-plugin-0.3.5.tgz", - "integrity": "sha512-3mLlbz+RI5mPKVXLwqH2tT4L9ngmMZlq2Z4zmMl/F83JSOi1PCdYlD6J4FHG2jRBES7iI37VTFDxiJoHbP9tFw==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/redux-devtool-plugin/-/redux-devtool-plugin-0.3.6.tgz", + "integrity": "sha512-3nM+O9C0Sv1+k3PdrWEPZfGTDppf+lKSvUj4tZcEOHv1XTwd8wZ23ZVz5+8wWy3UBzEFCbC/BNHl4ahEcIDbKA==", "dependencies": { - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/variable-core": "0.3.5", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/variable-core": "0.3.6", "inversify": "^6.0.1", "reflect-metadata": "~0.2.2", "styled-components": "^5" @@ -2994,14 +2994,14 @@ } }, "node_modules/@flowgram.ai/renderer": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/renderer/-/renderer-0.3.5.tgz", - "integrity": "sha512-YheWVjZ1CUIUfjfQwf7G/4jeyWsYWKG9+4zJW+co5U8isNAnVH2l+8Ahibx5fOIEnmzerOVRN73dKSSetwDdPg==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/renderer/-/renderer-0.3.6.tgz", + "integrity": "sha512-o2S1oeWfWSR7C2Y6TSFAjdcHqdOPBOBWNtAglB4nK1SdHT9/sRWt60rdyArrWajU9DLvG197RN8qzSRTNkDbsQ==", "dependencies": { - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/document": "0.3.5", - "@flowgram.ai/i18n": "0.3.5", - "@flowgram.ai/utils": "0.3.5", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/document": "0.3.6", + "@flowgram.ai/i18n": "0.3.6", + "@flowgram.ai/utils": "0.3.6", "inversify": "^6.0.1", "lodash": "^4.17.21", "reflect-metadata": "~0.2.2" @@ -3012,12 +3012,12 @@ } }, "node_modules/@flowgram.ai/select-box-plugin": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/select-box-plugin/-/select-box-plugin-0.3.5.tgz", - "integrity": "sha512-8I1+KoE9LQaDIfte/AyC4SwB9pcmb/HcQQNKklZbSPR8He3Fu4dLrTcquLO6dkRx/aF6dvugT3/78ise3mijoQ==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/select-box-plugin/-/select-box-plugin-0.3.6.tgz", + "integrity": "sha512-lsrFq01YUhLOFGjPhKQA6nmsNI9U1aM7hg3p1Fw4ILIHyRFZse8mvHrMvf/fjAPfVOdJTKjcsGfQPOjWTrhWFQ==", "dependencies": { - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/renderer": "0.3.5", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/renderer": "0.3.6", "inversify": "^6.0.1", "reflect-metadata": "~0.2.2" }, @@ -3027,13 +3027,13 @@ } }, "node_modules/@flowgram.ai/shortcuts-plugin": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/shortcuts-plugin/-/shortcuts-plugin-0.3.5.tgz", - "integrity": "sha512-68RV3LAgsZxeHvUpHCQpa4k80J06D9zpRgCjXfCpyBqUkXfMqp/uvv3pM/LBynl+4OyLzbFCxt0dsXDQP6mjNw==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/shortcuts-plugin/-/shortcuts-plugin-0.3.6.tgz", + "integrity": "sha512-RQ5fnqtqw2aXJTptV3yLEKFNl7mdd2q8CXQ7v8snqRUaB5vzI55M3SJUvCgmenayXSGktBofGcZ/mpA578lVKw==", "dependencies": { - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/document": "0.3.5", - "@flowgram.ai/utils": "0.3.5", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/document": "0.3.6", + "@flowgram.ai/utils": "0.3.6", "inversify": "^6.0.1", "reflect-metadata": "~0.2.2", "styled-components": "^5" @@ -3088,9 +3088,9 @@ } }, "node_modules/@flowgram.ai/utils": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/utils/-/utils-0.3.5.tgz", - "integrity": "sha512-t8KZ7c0+evAqo0e+wE3Kw58UGRkM0+DSit+X0wqerbVZe0K3j6fIIU/C0xuiVxhvxYAyCHpbNaJR0REE6jCcgg==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/utils/-/utils-0.3.6.tgz", + "integrity": "sha512-5RKx2JrEjlcMOFe81F2DnroQT195ycSmuzt71p2LP/JDzNM0nG5FDRFY9DN5xuvXAeDvbD9L61rZBIgaAIXFKw==", "dependencies": { "clsx": "^1.1.1", "inversify": "^6.0.1", @@ -3128,12 +3128,12 @@ } }, "node_modules/@flowgram.ai/variable-core": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/variable-core/-/variable-core-0.3.5.tgz", - "integrity": "sha512-A+FwAF3uFOnULsnRz6UeVofax3fR9nIY4f+9f2td1+SEN/sCrI15vmsb9rWynC2OfSdx3k+IMzuQrpP/BwGVPw==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/variable-core/-/variable-core-0.3.6.tgz", + "integrity": "sha512-IIssDNEpjyno7m+T3FiOzsrx4bM0qJGsZM+0MIF/CG8lbu//mtpd35SFEODePB8jZLg8sMU49/9f81LtWXXZsA==", "dependencies": { - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/utils": "0.3.5", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/utils": "0.3.6", "fast-equals": "^2.0.0", "inversify": "^6.0.1", "lodash": "^4.17.21", @@ -3164,15 +3164,15 @@ } }, "node_modules/@flowgram.ai/variable-layout": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/variable-layout/-/variable-layout-0.3.5.tgz", - "integrity": "sha512-ah0J5Jzj0nDHNMdjMrZ7VJtxi5DkVgolNhLjN6HyoC4JyxRTMqDSwT1JQI09pLoxqhC+LuEZ4D36eJqAY883uw==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/variable-layout/-/variable-layout-0.3.6.tgz", + "integrity": "sha512-f43k2OnOL7KXcuewQHX9Oy4t6cy4iotIYRnV5kvlD5JlZpPxCxKBtFeRPH6L/xXoKNCLA9JFv9IIBvO+omeU4A==", "dependencies": { - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/document": "0.3.5", - "@flowgram.ai/free-layout-core": "0.3.5", - "@flowgram.ai/utils": "0.3.5", - "@flowgram.ai/variable-core": "0.3.5", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/document": "0.3.6", + "@flowgram.ai/free-layout-core": "0.3.6", + "@flowgram.ai/utils": "0.3.6", + "@flowgram.ai/variable-core": "0.3.6", "inversify": "^6.0.1", "reflect-metadata": "~0.2.2", "styled-components": "^5" @@ -3227,16 +3227,16 @@ } }, "node_modules/@flowgram.ai/variable-plugin": { - "version": "0.3.5", - "resolved": "https://registry.npmmirror.com/@flowgram.ai/variable-plugin/-/variable-plugin-0.3.5.tgz", - "integrity": "sha512-Y7kgLgV/jZS/u9BfcstmxxHYi9NbGZqr2jsBXkOiV/+eYsSaVZW11YXZaC0ZNetl5/SVR68zbvzqjJV6nKIIHg==", + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/variable-plugin/-/variable-plugin-0.3.6.tgz", + "integrity": "sha512-b7q5/FjdbLTChuW2eLh708KDhLF4gdjzkXrjwWoyZ0vleo/Qs7S8NMuzHnhYusu5zdj0N0aFDOb+5jl7uPk2rw==", "dependencies": { - "@flowgram.ai/core": "0.3.5", - "@flowgram.ai/document": "0.3.5", - "@flowgram.ai/free-layout-core": "0.3.5", - "@flowgram.ai/utils": "0.3.5", - "@flowgram.ai/variable-core": "0.3.5", - "@flowgram.ai/variable-layout": "0.3.5", + "@flowgram.ai/core": "0.3.6", + "@flowgram.ai/document": "0.3.6", + "@flowgram.ai/free-layout-core": "0.3.6", + "@flowgram.ai/utils": "0.3.6", + "@flowgram.ai/variable-core": "0.3.6", + "@flowgram.ai/variable-layout": "0.3.6", "inversify": "^6.0.1", "reflect-metadata": "~0.2.2", "styled-components": "^5" @@ -4643,9 +4643,9 @@ } }, "node_modules/@uiw/codemirror-extensions-basic-setup": { - "version": "4.24.2", - "resolved": "https://registry.npmmirror.com/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.24.2.tgz", - "integrity": "sha512-wW/gjLRvVUeYyhdh2TApn25cvdcR+Rhg6R/j3eTOvXQzU1HNzNYCVH4YKVIfgtfdM/Xs+N8fkk+rbr1YvBppCg==", + "version": "4.25.1", + "resolved": "https://registry.npmmirror.com/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.1.tgz", + "integrity": "sha512-zxgA2QkvP3ZDKxTBc9UltNFTrSeFezGXcZtZj6qcsBxiMzowoEMP5mVwXcKjpzldpZVRuY+JCC+RsekEgid4vg==", "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", @@ -4669,20 +4669,20 @@ } }, "node_modules/@uiw/codemirror-theme-vscode": { - "version": "4.24.2", - "resolved": "https://registry.npmmirror.com/@uiw/codemirror-theme-vscode/-/codemirror-theme-vscode-4.24.2.tgz", - "integrity": "sha512-5rLvAMKNjqtHSy3BZnI1Q4ZLqUDDJC5fKOp8Mv5VjPqEmAxEAIGrIXBnhAFlRg5VjHbHEMtM1O5ugL3IeHoBdQ==", + "version": "4.25.1", + "resolved": "https://registry.npmmirror.com/@uiw/codemirror-theme-vscode/-/codemirror-theme-vscode-4.25.1.tgz", + "integrity": "sha512-vZXvErVDvHLFWCo78Y22R2grisGkWJFFKkdOu9+tcRjWfOVb7K3fDu/H4fDr6JujLTaL+LY/vP/LZYAGJXVqgA==", "dependencies": { - "@uiw/codemirror-themes": "4.24.2" + "@uiw/codemirror-themes": "4.25.1" }, "funding": { "url": "https://jaywcjlove.github.io/#/sponsor" } }, "node_modules/@uiw/codemirror-themes": { - "version": "4.24.2", - "resolved": "https://registry.npmmirror.com/@uiw/codemirror-themes/-/codemirror-themes-4.24.2.tgz", - "integrity": "sha512-0fQusJE08APL+1WM0xYqvampVM2RpABHjvZbcJWHdRc+teZbrP8907VW1ZX1tO/7/xgRRt4Fc3RHPCcTs7b0NQ==", + "version": "4.25.1", + "resolved": "https://registry.npmmirror.com/@uiw/codemirror-themes/-/codemirror-themes-4.25.1.tgz", + "integrity": "sha512-6o8tQ8bdq14RuVFpZ7l9u8KnuPq824uG3U1VV933Uhv8mfaxaoaOQSjv6T2bQUPhjH6ZlEu5+tAMkOfIL21eIQ==", "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", @@ -4698,15 +4698,15 @@ } }, "node_modules/@uiw/react-codemirror": { - "version": "4.24.2", - "resolved": "https://registry.npmmirror.com/@uiw/react-codemirror/-/react-codemirror-4.24.2.tgz", - "integrity": "sha512-kp7DhTq4RR+M2zJBQBrHn1dIkBrtOskcwJX4vVsKGByReOvfMrhqRkGTxYMRDTX6x75EG2mvBJPDKYcUQcHWBw==", + "version": "4.25.1", + "resolved": "https://registry.npmmirror.com/@uiw/react-codemirror/-/react-codemirror-4.25.1.tgz", + "integrity": "sha512-eESBKHndoYkaEGlKCwRO4KrnTw1HkWBxVpEeqntoWTpoFEUYxdLWUYmkPBVk4/u8YzVy9g91nFfIRpqe5LjApg==", "dependencies": { "@babel/runtime": "^7.18.6", "@codemirror/commands": "^6.1.0", "@codemirror/state": "^6.1.1", "@codemirror/theme-one-dark": "^6.0.0", - "@uiw/codemirror-extensions-basic-setup": "4.24.2", + "@uiw/codemirror-extensions-basic-setup": "4.25.1", "codemirror": "^6.0.0" }, "funding": { diff --git a/ui/package.json b/ui/package.json index 78b7ede42..7dd99ebf0 100644 --- a/ui/package.json +++ b/ui/package.json @@ -13,20 +13,20 @@ "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.11.2", "@codemirror/legacy-modes": "^6.5.1", - "@flowgram.ai/document": "^0.3.5", - "@flowgram.ai/fixed-layout-editor": "^0.3.5", - "@flowgram.ai/minimap-plugin": "^0.3.5", + "@flowgram.ai/document": "^0.3.6", + "@flowgram.ai/fixed-layout-editor": "^0.3.6", + "@flowgram.ai/minimap-plugin": "^0.3.6", "@tabler/icons-react": "^3.34.1", - "@uiw/codemirror-extensions-basic-setup": "^4.24.2", - "@uiw/codemirror-theme-vscode": "^4.24.2", - "@uiw/react-codemirror": "^4.24.2", + "@uiw/codemirror-extensions-basic-setup": "^4.25.1", + "@uiw/codemirror-theme-vscode": "^4.25.1", + "@uiw/react-codemirror": "^4.25.1", "ahooks": "^3.9.0", "antd": "^5.27.0", "antd-zod": "^7.0.0", "clsx": "^2.1.1", "cron-parser": "^5.3.0", "file-saver": "^2.0.5", - "i18next": "^25.3.4", + "i18next": "^25.3.6", "i18next-browser-languagedetector": "^8.2.0", "immer": "^10.1.1", "nanoid": "^5.1.5", @@ -43,8 +43,8 @@ }, "devDependencies": { "@eslint/js": "^9.33.0", - "@tailwindcss/postcss": "^4.1.11", - "@tailwindcss/vite": "^4.1.11", + "@tailwindcss/postcss": "^4.1.12", + "@tailwindcss/vite": "^4.1.12", "@types/file-saver": "^2.0.7", "@types/fs-extra": "^11.0.4", "@types/node": "^22.16.5", @@ -63,7 +63,7 @@ "eslint-plugin-react-refresh": "^0.4.20", "fs-extra": "^11.3.1", "prettier": "^3.6.2", - "tailwindcss": "^4.1.11", + "tailwindcss": "^4.1.12", "typescript": "^5.8.3", "typescript-eslint": "^8.39.1", "vite": "^6.3.5" diff --git a/ui/src/components/CodeInput.tsx b/ui/src/components/CodeInput.tsx index 90c1fc4a6..9064bf7ed 100644 --- a/ui/src/components/CodeInput.tsx +++ b/ui/src/components/CodeInput.tsx @@ -7,7 +7,7 @@ import { shell } from "@codemirror/legacy-modes/mode/shell"; import { basicSetup } from "@uiw/codemirror-extensions-basic-setup"; import { vscodeDark, vscodeLight } from "@uiw/codemirror-theme-vscode"; import CodeMirror, { type ReactCodeMirrorProps, type ReactCodeMirrorRef } from "@uiw/react-codemirror"; -import { useFocusWithin } from "ahooks"; +import { useFocusWithin, useHover } from "ahooks"; import { theme } from "antd"; import { useBrowserTheme } from "@/hooks"; @@ -24,7 +24,8 @@ const CodeInput = ({ className, style, disabled, language, ...props }: CodeInput const { theme: browserTheme } = useBrowserTheme(); const cmRef = useRef(null); - const isFocusWithin = useFocusWithin(cmRef.current?.editor); + const isFocusing = useFocusWithin(cmRef.current?.editor); + const isHovering = useHover(cmRef.current?.editor); const cmTheme = useMemo(() => { if (browserTheme === "dark") { @@ -66,13 +67,30 @@ const CodeInput = ({ className, style, disabled, language, ...props }: CodeInput return (
diff --git a/ui/src/components/workflow/designer/Designer.tsx b/ui/src/components/workflow/designer/Designer.tsx index 308cf3a75..b2f682b20 100644 --- a/ui/src/components/workflow/designer/Designer.tsx +++ b/ui/src/components/workflow/designer/Designer.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from "react"; +import { forwardRef, useImperativeHandle, useMemo, useRef } from "react"; import { ConstantKeys, EditorRenderer, @@ -119,6 +119,18 @@ const Designer = forwardRef( }), ], + onInit: (ctx) => { + const callback = () => { + if (rendered.current) { + onDocumentChange?.(flowgramEditorRef.current!); + } + }; + ctx.document.onNodeCreate(callback); + ctx.document.onNodeUpdate(callback); + ctx.document.onNodeDispose(callback); + ctx.document.originTree.onTreeChange(callback); + }, + onAllLayersRendered: (ctx) => { rendered.current = true; @@ -128,50 +140,34 @@ const Designer = forwardRef( }, 1); }, }), - [themeToken, initialData, readonly] + [themeToken, initialData, readonly, onDocumentChange] ); - useEffect(() => { - const callback = () => { - if (rendered.current) { - onDocumentChange?.(flowgramEditorRef.current!); - } - }; - const d1 = flowgramEditorRef.current?.document?.onNodeCreate(callback); - const d2 = flowgramEditorRef.current?.document?.onNodeUpdate(callback); - const d3 = flowgramEditorRef.current?.document?.onNodeDispose(callback); - const d4 = flowgramEditorRef.current?.document?.renderTree?.onTreeChange(callback); - - return () => { - d1?.dispose(); - d2?.dispose(); - d3?.dispose(); - d4?.dispose(); - }; - }, [onDocumentChange]); - useImperativeHandle(ref, () => { return { + get clipboard() { + return flowgramEditorRef.current!.clipboard; + }, get container() { return flowgramEditorRef.current!.container; }, get document() { return flowgramEditorRef.current!.document; }, - get playground() { - return flowgramEditorRef.current!.playground; + get history() { + return flowgramEditorRef.current!.history; }, get operation() { return flowgramEditorRef.current!.operation; }, - get clipboard() { - return flowgramEditorRef.current!.clipboard; + get playground() { + return flowgramEditorRef.current!.playground; }, get selection() { return flowgramEditorRef.current!.selection; }, - get history() { - return flowgramEditorRef.current!.history; + get tools() { + return flowgramEditorRef.current!.tools; }, get(identifier) { diff --git a/ui/src/components/workflow/designer/Toolbar.tsx b/ui/src/components/workflow/designer/Toolbar.tsx index 666feeb04..1479a9cff 100644 --- a/ui/src/components/workflow/designer/Toolbar.tsx +++ b/ui/src/components/workflow/designer/Toolbar.tsx @@ -19,7 +19,7 @@ const Toolbar = ({ className, style }: ToolbarProps) => { const ctx = useClientContext(); const { playground } = ctx; - const tools = usePlaygroundTools({ minZoom: 0.1, maxZoom: 3, padding: 48 }); + const tools = usePlaygroundTools({ minZoom: 0.1, maxZoom: 3 }); const refresh = useRefresh(); diff --git a/ui/src/components/workflow/designer/_util.ts b/ui/src/components/workflow/designer/_util.ts new file mode 100644 index 000000000..78d3c1fa2 --- /dev/null +++ b/ui/src/components/workflow/designer/_util.ts @@ -0,0 +1,182 @@ +import { FlowNodeBaseType, type FlowNodeEntity } from "@flowgram.ai/fixed-layout-editor"; +import { Immer } from "immer"; +import { nanoid } from "nanoid"; + +import { type NodeJSON, NodeType } from "./nodes/typings"; + +/** + * 返回一个新的节点 ID。 + * @returns {String} + */ +export const newNodeId = () => nanoid(); + +/** + * 克隆节点 JSON 对象。节点及其子节点 ID 均会重新分配。 + * @param {NodeJSON} node + * @param {Object} options + * @returns {NodeJSON} + */ +export const duplicateNodeJSON = (node: NodeJSON, options?: { withCopySuffix?: boolean }) => { + const { produce } = new Immer({ autoFreeze: false }); + const deepClone = (node: NodeJSON, { withCopySuffix, nodeIdMap }: { withCopySuffix: boolean; nodeIdMap: Map }) => { + return produce(node, (draft) => { + draft.data ??= {}; + draft.id = newNodeId(); + draft.data.name = withCopySuffix ? `${draft.data?.name || ""}-copy` : `${draft.data?.name || ""}`; + + nodeIdMap.set(node.id, draft.id); // 原节点 ID 映射到新节点 ID + + if (draft.blocks) { + draft.blocks = draft.blocks.map((block) => deepClone(block as NodeJSON, { withCopySuffix: false, nodeIdMap })); + } + + if (draft.data?.config) { + switch (draft.type) { + case NodeType.BizDeploy: + { + const prevNodeId = draft.data.config.certificate?.split("#")?.[0]; + if (nodeIdMap.has(prevNodeId)) { + draft.data.config = { + ...draft.data.config, + certificate: `${nodeIdMap.get(prevNodeId)}#certificate`, + }; + } + } + break; + + case NodeType.Condition: + { + const stack = [] as any[]; + const expr = draft.data.config.expression; + if (expr) { + stack.push(expr); + while (stack.length > 0) { + const n = stack.pop()!; + if ("left" in n) { + stack.push(n.left); + if ("selector" in n.left) { + const prevNodeId = n.left.selector.id; + if (nodeIdMap.has(prevNodeId)) { + n.left.selector.id = nodeIdMap.get(prevNodeId)!; + } + } + } + if ("right" in n) { + stack.push(n.right); + } + } + draft.data.config = { + ...draft.data.config, + expression: expr, + }; + } + } + break; + } + } + + return draft; + }); + }; + + return deepClone(node, { withCopySuffix: options?.withCopySuffix ?? true, nodeIdMap: new Map() }); +}; + +/** + * 获取指定节点到根节点为止的所有前序节点。不包括自身和根节点或开始节点。 + * @param {FlowNodeEntity} node + * @returns {FlowNodeEntity[]} + */ +export const getAllPreviousNodes = (node: FlowNodeEntity): FlowNodeEntity[] => { + if (node == null) return []; + + // TODO: 不应该获取到旁路分支 + // // 先获取单一链路(即不包含分支)的全部节点 + // const chains: FlowNodeEntity[] = []; + // let chain: FlowNodeEntity | undefined = node; + // while (chain) { + // if (chain.isStart || chain.flowNodeType === FlowNodeBaseType.ROOT) { + // break; + // } + + // chains.push(chain); + // chain = chain.pre ?? chain.parent; + // } + + // 再获取实际的全部节点 + const visited = new Set(); + const result: FlowNodeEntity[] = []; + let current: FlowNodeEntity | undefined = node; + while (current) { + if (current.isStart || current.flowNodeType === FlowNodeBaseType.ROOT) { + break; + } + + if (current.flowNodeType === NodeType.Condition) { + /** + * condition + * blockIcon + * inlineBlocks + * branchBlock_1 + * blockOrderIcon + * ... + * branchBlock_2 + * blockOrderIcon + * ... + */ + current.lastBlock?.blocks?.forEach((block) => { + block.allChildren?.forEach((child) => { + if (!visited.has(child.id)) { + visited.add(child.id); + result.push(child); + } + }); + }); + } else if (current.flowNodeType === NodeType.TryCatch) { + /** + * tryCatch + * blockIcon + * mainInlineBlocks + * tryBlock + * trySlot + * ... + * catchInlineBlocks + * catchBlock_1 + * blockOrderIcon + * ... + * end + * catchBlock_2 + * blockOrderIcon + * ... + * end + */ + current.lastBlock?.blocks?.forEach((block) => { + block.allChildren?.forEach((child) => { + if (!visited.has(child.id)) { + visited.add(child.id); + result.push(child); + } + }); + }); + } + + if (!visited.has(current.id)) { + visited.add(current.id); + result.push(current); + } + current = current.pre ?? current.parent; + } + + const prevNodes = result.filter((e) => { + if (e.id === node.id) return false; + if (e.isTypeOrExtendType(FlowNodeBaseType.BLOCK_ICON)) return false; + if (e.isTypeOrExtendType(FlowNodeBaseType.BLOCK_ORDER_ICON)) return false; + if (e.isTypeOrExtendType(FlowNodeBaseType.INLINE_BLOCKS)) return false; + if (e.isTypeOrExtendType("trySlot")) return false; + + return true; + }); + console.log(node.document.root); + console.log(prevNodes); + return prevNodes; +}; diff --git a/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx index 57a220304..47f6abf8a 100644 --- a/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx +++ b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx @@ -555,13 +555,13 @@ const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) }, t("common.errmsg.domain_invalid")), contactEmail: z.email(t("common.errmsg.email_invalid")), challengeType: z.string().nullish(), - provider: z.string().nonempty(t("workflow_node.apply.form.provider.placeholder")), - providerAccessId: z.string().nonempty(t("workflow_node.apply.form.provider_access.placeholder")), + provider: z.string(t("workflow_node.apply.form.provider.placeholder")).nonempty(t("workflow_node.apply.form.provider.placeholder")), + providerAccessId: z.string(t("workflow_node.apply.form.provider_access.placeholder")).nonempty(t("workflow_node.apply.form.provider_access.placeholder")), providerConfig: z.any().nullish(), caProvider: z.string().nullish(), caProviderAccessId: z.string().nullish(), caProviderConfig: z.any().nullish(), - keyAlgorithm: z.string().nonempty(t("workflow_node.apply.form.key_algorithm.placeholder")), + keyAlgorithm: z.string(t("workflow_node.apply.form.key_algorithm.placeholder")).nonempty(t("workflow_node.apply.form.key_algorithm.placeholder")), nameservers: z .string() .nullish() diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigForm.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigForm.tsx index b762100cb..8d9ee1b71 100644 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigForm.tsx +++ b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigForm.tsx @@ -12,10 +12,10 @@ import DeploymentProviderPicker from "@/components/provider/DeploymentProviderPi import DeploymentProviderSelect from "@/components/provider/DeploymentProviderSelect"; import Show from "@/components/Show"; import { ACCESS_USAGES, DEPLOYMENT_PROVIDERS, accessProvidersMap, deploymentProvidersMap } from "@/domain/provider"; -import { type WorkflowNodeConfigForDeploy, WorkflowNodeType, defaultNodeConfigForDeploy } from "@/domain/workflow"; -import { useAntdForm, useZustandShallowSelector } from "@/hooks"; -import { useWorkflowStore } from "@/stores/workflow"; +import { type WorkflowNodeConfigForDeploy, defaultNodeConfigForDeploy } from "@/domain/workflow"; +import { useAntdForm } from "@/hooks"; +import { getAllPreviousNodes } from "../_util"; import { FormNestedFieldsContextProvider, NodeFormContextProvider } from "./_context"; import BizDeployNodeConfigFormProvider1PanelConsole from "./BizDeployNodeConfigFormProvider1PanelConsole"; import BizDeployNodeConfigFormProvider1PanelSite from "./BizDeployNodeConfigFormProvider1PanelSite"; @@ -126,13 +126,21 @@ const BizDeployNodeConfigForm = ({ node, ...props }: BizDeployNodeConfigFormProp const { token: themeToken } = theme.useToken(); - const { getWorkflowOuptutBeforeId } = useWorkflowStore(useZustandShallowSelector(["updateNode", "getWorkflowOuptutBeforeId"])); - const initialValues = useMemo(() => { return getNodeForm(node)?.getValueIn("config") as WorkflowNodeConfigForDeploy | undefined; }, [node]); - const formSchema = getSchema({ i18n }); + const formSchema = getSchema({ i18n }).superRefine((values, ctx) => { + if (values.certificateOutputNodeId) { + if (!certificateOutputNodeIdOptions.some((option) => option.value === values.certificateOutputNodeId)) { + ctx.addIssue({ + code: "custom", + message: t("workflow_node.deploy.form.certificate_output_node_id.placeholder"), + path: ["certificateOutputNodeId"], + }); + } + } + }); const formRule = createSchemaFieldRule(formSchema); const { form: formInst, formProps } = useAntdForm({ form: props.form, @@ -142,6 +150,17 @@ const BizDeployNodeConfigForm = ({ node, ...props }: BizDeployNodeConfigFormProp const fieldProvider = Form.useWatch("provider", { form: formInst, preserve: true }); + const certificateOutputNodeIdOptions = useMemo(() => { + return getAllPreviousNodes(node) + .filter((node) => node.flowNodeType === NodeType.BizApply || node.flowNodeType === NodeType.BizUpload) + .map((node) => { + return { + label: getNodeForm(node)?.getValueIn("name"), + value: node.id, + }; + }); + }, [node]); + const [showProviderAccess, setShowProviderAccess] = useState(false); useEffect(() => { // 内置的部署提供商(如本地部署)无需显示授权信息字段 @@ -153,25 +172,6 @@ const BizDeployNodeConfigForm = ({ node, ...props }: BizDeployNodeConfigFormProp } }, [fieldProvider]); - // TODO: 适配 flowgram - const certificateCandidates = useMemo(() => { - const previousNodes = getWorkflowOuptutBeforeId(node.id, "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); - }, [node.id]); - const NestedProviderConfigFields = useMemo(() => { /* 注意:如果追加新的子组件,请保持以 ASCII 排序。 @@ -369,7 +369,6 @@ const BizDeployNodeConfigForm = ({ node, ...props }: BizDeployNodeConfigFormProp const handleProviderPick = (value: string) => { formInst.setFieldValue("provider", value); - console.log(); }; const handleProviderSelect = (value?: string | undefined) => { @@ -380,7 +379,7 @@ const BizDeployNodeConfigForm = ({ node, ...props }: BizDeployNodeConfigFormProp const oldValues = formInst.getFieldsValue(); const newValues: Record = {}; for (const key in oldValues) { - if (key === "provider" || key === "providerAccessId" || key === "certificate" || key === "skipOnLastSucceeded") { + if (key === "certificateOutputNodeId" || key === "provider" || key === "providerAccessId" || key === "skipOnLastSucceeded") { newValues[key] = oldValues[key]; } else { delete newValues[key]; @@ -455,22 +454,24 @@ const BizDeployNodeConfigForm = ({ node, ...props }: BizDeployNodeConfigFormProp ({ key: s, label: t(`access.form.ssh_auth_method.option.${s}.label`), value: s, }))} - placeholder={t("access.form.ssh_auth_method.placeholder")} /> @@ -201,17 +200,13 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues
- + ({ + key: s, + label: t(`access.form.ssh_auth_method.option.${s}.label`), + value: s, + }))} + /> diff --git a/ui/src/components/access/AccessSelect.tsx b/ui/src/components/access/AccessSelect.tsx index 239bde584..cf028db9e 100644 --- a/ui/src/components/access/AccessSelect.tsx +++ b/ui/src/components/access/AccessSelect.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useAsyncEffect } from "ahooks"; import { Avatar, Select, type SelectProps, Typography, theme } from "antd"; import { type AccessModel } from "@/domain/access"; @@ -15,9 +16,7 @@ const AccessSelect = ({ onFilter, ...props }: AccessTypeSelectProps) => { const { token: themeToken } = theme.useToken(); const { accesses, loadedAtOnce, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "loadedAtOnce", "fetchAccesses"])); - useEffect(() => { - fetchAccesses(false); - }, []); + useAsyncEffect(() => fetchAccesses(false), []); const [options, setOptions] = useState>([]); useEffect(() => { diff --git a/ui/src/components/provider/DeploymentProviderPicker.tsx b/ui/src/components/provider/DeploymentProviderPicker.tsx index 5e3dddcc7..ae2e5122e 100644 --- a/ui/src/components/provider/DeploymentProviderPicker.tsx +++ b/ui/src/components/provider/DeploymentProviderPicker.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useSize } from "ahooks"; +import { useAsyncEffect, useSize } from "ahooks"; import { Avatar, Card, Checkbox, Empty, Flex, Input, type InputRef, Tabs, Tooltip, Typography } from "antd"; import Show from "@/components/Show"; @@ -23,9 +23,7 @@ const DeploymentProviderPicker = ({ className, style, autoFocus, gap = "middle", const { t } = useTranslation(); const { accesses, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "fetchAccesses"])); - useEffect(() => { - fetchAccesses(false); - }, []); + useAsyncEffect(() => fetchAccesses(false), []); const wrapperRef = useRef(null); const wrapperSize = useSize(wrapperRef); diff --git a/ui/src/components/workflow/WorkflowElement.tsx b/ui/src/components/workflow/WorkflowElement.tsx deleted file mode 100644 index 7b1325a21..000000000 --- a/ui/src/components/workflow/WorkflowElement.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { memo, useMemo } from "react"; - -import { type WorkflowNode, WorkflowNodeType } from "@/domain/workflow"; - -import ApplyNode from "./node/ApplyNode"; -import BranchNode from "./node/BranchNode"; -import ConditionNode from "./node/ConditionNode"; -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"; - -/** - * - * @deprecated - */ -export interface WorkflowElementProps { - node: WorkflowNode; - disabled?: boolean; - branchId?: string; - branchIndex?: number; -} - -/** - * - * @deprecated - */ -const WorkflowElement = ({ node, disabled, branchId, branchIndex }: WorkflowElementProps) => { - const nodeEl = useMemo(() => { - switch (node.type) { - case WorkflowNodeType.Start: - return ; - - case WorkflowNodeType.Apply: - return ; - - case WorkflowNodeType.Upload: - return ; - - case WorkflowNodeType.Monitor: - return ; - - case WorkflowNodeType.Deploy: - return ; - - case WorkflowNodeType.Notify: - return ; - - case WorkflowNodeType.Branch: - return ; - - case WorkflowNodeType.ExecuteResultBranch: - return ; - - case WorkflowNodeType.ExecuteSuccess: - case WorkflowNodeType.ExecuteFailure: - return ; - - case WorkflowNodeType.Condition: - return ; - - case WorkflowNodeType.End: - return ; - - default: - console.warn(`[certimate] unsupported workflow node type: '${node.type}'`); - return ; - } - }, [node, disabled, branchId, branchIndex]); - - return <>{nodeEl}; -}; - -export default memo(WorkflowElement); diff --git a/ui/src/components/workflow/WorkflowElements.tsx b/ui/src/components/workflow/WorkflowElements.tsx deleted file mode 100644 index c9d64876f..000000000 --- a/ui/src/components/workflow/WorkflowElements.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useMemo } from "react"; - -import WorkflowElement from "@/components/workflow/WorkflowElement"; -import { WorkflowNodeType, newNode } from "@/domain/workflow"; -import { useZustandShallowSelector } from "@/hooks"; -import { useWorkflowStore } from "@/stores/workflow"; - -/** - * - * @deprecated - */ -export interface WorkflowElementsProps { - className?: string; - style?: React.CSSProperties; - disabled?: boolean; -} - -/** - * - * @deprecated - */ -const WorkflowElements = ({ className, style, disabled }: WorkflowElementsProps) => { - const { workflow } = useWorkflowStore(useZustandShallowSelector(["workflow"])); - - const elements = useMemo(() => { - const root = workflow.draft; - const nodes: JSX.Element[] = []; - - let current = root as typeof root | undefined; - while (current) { - nodes.push(); - current = current.next; - } - - nodes.push(); - - return nodes; - }, [workflow, disabled]); - - return ( -
-
{elements}
-
- ); -}; - -export default WorkflowElements; diff --git a/ui/src/components/workflow/WorkflowElementsContainer.tsx b/ui/src/components/workflow/WorkflowElementsContainer.tsx deleted file mode 100644 index 339315bc2..000000000 --- a/ui/src/components/workflow/WorkflowElementsContainer.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { useState } from "react"; -import { IconMaximize, IconMinus, IconPlus } from "@tabler/icons-react"; -import { Button, Card, Typography } from "antd"; - -import WorkflowElements from "@/components/workflow/WorkflowElements"; -import { mergeCls } from "@/utils/css"; - -/** - * - * @deprecated - */ -export interface WorkflowElementsProps { - className?: string; - style?: React.CSSProperties; - disabled?: boolean; -} - -/** - * - * @deprecated - */ -const WorkflowElementsContainer = ({ className, style, disabled }: WorkflowElementsProps) => { - const [scale, setScale] = useState(1); - - const MIN_SCALE = 0.2; - const MAX_SCALE = 2; - const STEP_SCALE = 0.05; - - return ( -
-
-
-
-
- -
-
-
-
- -
-
-
- -
-
-
-
-
-
-
- ); -}; - -export default WorkflowElementsContainer; diff --git a/ui/src/components/workflow/designer/Designer.tsx b/ui/src/components/workflow/designer/Designer.tsx index b2f682b20..04b9c2786 100644 --- a/ui/src/components/workflow/designer/Designer.tsx +++ b/ui/src/components/workflow/designer/Designer.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useImperativeHandle, useMemo, useRef } from "react"; +import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from "react"; import { ConstantKeys, EditorRenderer, @@ -119,18 +119,6 @@ const Designer = forwardRef( }), ], - onInit: (ctx) => { - const callback = () => { - if (rendered.current) { - onDocumentChange?.(flowgramEditorRef.current!); - } - }; - ctx.document.onNodeCreate(callback); - ctx.document.onNodeUpdate(callback); - ctx.document.onNodeDispose(callback); - ctx.document.originTree.onTreeChange(callback); - }, - onAllLayersRendered: (ctx) => { rendered.current = true; @@ -143,6 +131,16 @@ const Designer = forwardRef( [themeToken, initialData, readonly, onDocumentChange] ); + useEffect(() => { + const d = flowgramEditorRef.current!.document.originTree.onTreeChange(() => { + if (rendered.current) { + onDocumentChange?.(flowgramEditorRef.current!); + } + }); + + return () => d.dispose(); + }, [onDocumentChange]); + useImperativeHandle(ref, () => { return { get clipboard() { diff --git a/ui/src/components/workflow/designer/_util.ts b/ui/src/components/workflow/designer/_util.ts index 78d3c1fa2..ab2bb5386 100644 --- a/ui/src/components/workflow/designer/_util.ts +++ b/ui/src/components/workflow/designer/_util.ts @@ -1,15 +1,9 @@ import { FlowNodeBaseType, type FlowNodeEntity } from "@flowgram.ai/fixed-layout-editor"; -import { Immer } from "immer"; -import { nanoid } from "nanoid"; + +import { type WorkflowNode as _WorkflowNode, duplicateNode as _duplicateNode } from "@/domain/workflow"; import { type NodeJSON, NodeType } from "./nodes/typings"; -/** - * 返回一个新的节点 ID。 - * @returns {String} - */ -export const newNodeId = () => nanoid(); - /** * 克隆节点 JSON 对象。节点及其子节点 ID 均会重新分配。 * @param {NodeJSON} node @@ -17,69 +11,7 @@ export const newNodeId = () => nanoid(); * @returns {NodeJSON} */ export const duplicateNodeJSON = (node: NodeJSON, options?: { withCopySuffix?: boolean }) => { - const { produce } = new Immer({ autoFreeze: false }); - const deepClone = (node: NodeJSON, { withCopySuffix, nodeIdMap }: { withCopySuffix: boolean; nodeIdMap: Map }) => { - return produce(node, (draft) => { - draft.data ??= {}; - draft.id = newNodeId(); - draft.data.name = withCopySuffix ? `${draft.data?.name || ""}-copy` : `${draft.data?.name || ""}`; - - nodeIdMap.set(node.id, draft.id); // 原节点 ID 映射到新节点 ID - - if (draft.blocks) { - draft.blocks = draft.blocks.map((block) => deepClone(block as NodeJSON, { withCopySuffix: false, nodeIdMap })); - } - - if (draft.data?.config) { - switch (draft.type) { - case NodeType.BizDeploy: - { - const prevNodeId = draft.data.config.certificate?.split("#")?.[0]; - if (nodeIdMap.has(prevNodeId)) { - draft.data.config = { - ...draft.data.config, - certificate: `${nodeIdMap.get(prevNodeId)}#certificate`, - }; - } - } - break; - - case NodeType.Condition: - { - const stack = [] as any[]; - const expr = draft.data.config.expression; - if (expr) { - stack.push(expr); - while (stack.length > 0) { - const n = stack.pop()!; - if ("left" in n) { - stack.push(n.left); - if ("selector" in n.left) { - const prevNodeId = n.left.selector.id; - if (nodeIdMap.has(prevNodeId)) { - n.left.selector.id = nodeIdMap.get(prevNodeId)!; - } - } - } - if ("right" in n) { - stack.push(n.right); - } - } - draft.data.config = { - ...draft.data.config, - expression: expr, - }; - } - } - break; - } - } - - return draft; - }); - }; - - return deepClone(node, { withCopySuffix: options?.withCopySuffix ?? true, nodeIdMap: new Map() }); + return _duplicateNode(node as _WorkflowNode, options); }; /** @@ -176,7 +108,7 @@ export const getAllPreviousNodes = (node: FlowNodeEntity): FlowNodeEntity[] => { return true; }); - console.log(node.document.root); - console.log(prevNodes); + // console.log(node.document.root); + // console.log(prevNodes); return prevNodes; }; diff --git a/ui/src/components/workflow/designer/elements/Adder.tsx b/ui/src/components/workflow/designer/elements/Adder.tsx index ef2307caf..f52c1d320 100644 --- a/ui/src/components/workflow/designer/elements/Adder.tsx +++ b/ui/src/components/workflow/designer/elements/Adder.tsx @@ -31,12 +31,12 @@ const Adder = ({ from, hoverActivated }: AdderProps) => { }) .reduce( (acc, registry) => { - let group = acc.find((item) => item!.key === registry.kindType); + let group = acc.find((item) => item!.key === registry.kind); if (!group) { group = { - key: registry.kindType, + key: registry.kind, type: "group", - label: registry.kindType ? t(`workflow_node.kind.${registry.kindType}`) : null, + label: registry.kind ? t(`workflow_node.kind.${registry.kind}`) : null, children: [], }; acc.push(group); diff --git a/ui/src/components/workflow/designer/elements/BranchAdder.tsx b/ui/src/components/workflow/designer/elements/BranchAdder.tsx index 9b8c52d17..5723be277 100644 --- a/ui/src/components/workflow/designer/elements/BranchAdder.tsx +++ b/ui/src/components/workflow/designer/elements/BranchAdder.tsx @@ -18,11 +18,15 @@ const BranchAdder = ({ node }: BranchAdderProps) => { let block: FlowNodeEntity; switch (node.flowNodeType) { case NodeType.Condition: - block = operation.addBlock(node, BranchBlockNodeRegistry.onAdd!(ctx, node)); + { + block = operation.addBlock(node, BranchBlockNodeRegistry.onAdd!(ctx, node)); + } break; case NodeType.TryCatch: - block = operation.addBlock(node, CatchBlockNodeRegistry.onAdd!(ctx, node)); + { + block = operation.addBlock(node, CatchBlockNodeRegistry.onAdd!(ctx, node)); + } break; default: @@ -38,7 +42,8 @@ const BranchAdder = ({ node }: BranchAdderProps) => { }, 1); }; - return playground.config.readonlyOrDisabled ? null : ( + // TryCatch 暂不支持添加分支 + return playground.config.readonlyOrDisabled || node.flowNodeType === NodeType.TryCatch ? null : (
node.firstChild?.renderData?.toggleMouseEnter()} diff --git a/ui/src/components/workflow/designer/elements/index.ts b/ui/src/components/workflow/designer/elements/index.ts index f975f718c..ebf9b0bae 100644 --- a/ui/src/components/workflow/designer/elements/index.ts +++ b/ui/src/components/workflow/designer/elements/index.ts @@ -17,14 +17,13 @@ export const getAllElements = () => { [FlowRendererKey.COLLAPSE]: Collapse, [FlowRendererKey.TRY_CATCH_COLLAPSE]: TryCatchCollapse, - [FlowRendererKey.SLOT_COLLPASE]: Null, + [FlowRendererKey.SLOT_COLLAPSE]: Null, [FlowRendererKey.DRAG_NODE]: DragNode, [FlowRendererKey.DRAG_HIGHLIGHT_ADDER]: DragHighlightAdder, [FlowRendererKey.DRAG_BRANCH_HIGHLIGHT_ADDER]: DragHighlightAdder, [FlowRendererKey.DRAGGABLE_ADDER]: DraggingAdder, - [FlowRendererKey.CONTEXT_MENU_POPOVER]: Null, [FlowRendererKey.SELECTOR_BOX_POPOVER]: Null, }; }; diff --git a/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx index 47f6abf8a..7c21001b8 100644 --- a/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx +++ b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx @@ -3,7 +3,7 @@ import { getI18n, useTranslation } from "react-i18next"; import { Link } from "react-router"; import { type FlowNodeEntity, getNodeForm } from "@flowgram.ai/fixed-layout-editor"; import { IconChevronRight, IconCircleMinus, IconPlus } from "@tabler/icons-react"; -import { useControllableValue } from "ahooks"; +import { useAsyncEffect, useControllableValue } from "ahooks"; import { type AnchorProps, AutoComplete, Button, Divider, Flex, Form, type FormInstance, Input, InputNumber, Select, Switch, Typography } from "antd"; import { createSchemaFieldRule } from "antd-zod"; import { z } from "zod"; @@ -15,7 +15,7 @@ import ACMEDns01ProviderSelect from "@/components/provider/ACMEDns01ProviderSele import CAProviderSelect from "@/components/provider/CAProviderSelect"; import Show from "@/components/Show"; import { ACCESS_USAGES, ACME_DNS01_PROVIDERS, accessProvidersMap, acmeDns01ProvidersMap, caProvidersMap } from "@/domain/provider"; -import { type WorkflowNodeConfigForApply, defaultNodeConfigForApply } from "@/domain/workflow"; +import { type WorkflowNodeConfigForBizApply, defaultNodeConfigForBizApply } from "@/domain/workflow"; import { useAntdForm, useZustandShallowSelector } from "@/hooks"; import { useAccessesStore } from "@/stores/access"; import { useContactEmailsStore } from "@/stores/contact"; @@ -46,7 +46,7 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps) const { accesses } = useAccessesStore(useZustandShallowSelector("accesses")); const initialValues = useMemo(() => { - return getNodeForm(node)?.getValueIn("config") as WorkflowNodeConfigForApply | undefined; + return getNodeForm(node)?.getValueIn("config") as WorkflowNodeConfigForBizApply | undefined; }, [node]); const formSchema = getSchema({ i18n }); @@ -245,6 +245,16 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps) + + ({ - label: e, - value: e, - }))} - placeholder={t("workflow_node.apply.form.key_algorithm.placeholder")} - /> - - void }) => { const { emails, fetchEmails, removeEmail } = useContactEmailsStore(); - useEffect(() => { - fetchEmails(false); - }, []); + useAsyncEffect(() => fetchEmails(false), []); const [value, setValue] = useControllableValue(props, { valuePropName: "value", @@ -538,7 +536,7 @@ const getInitialValues = (): Nullish>> => { domains: "", provider: "", providerAccessId: "", - ...defaultNodeConfigForApply(), + ...defaultNodeConfigForBizApply(), }; }; diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigForm.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigForm.tsx index 8d9ee1b71..f9d90cd88 100644 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigForm.tsx +++ b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigForm.tsx @@ -12,7 +12,7 @@ import DeploymentProviderPicker from "@/components/provider/DeploymentProviderPi import DeploymentProviderSelect from "@/components/provider/DeploymentProviderSelect"; import Show from "@/components/Show"; import { ACCESS_USAGES, DEPLOYMENT_PROVIDERS, accessProvidersMap, deploymentProvidersMap } from "@/domain/provider"; -import { type WorkflowNodeConfigForDeploy, defaultNodeConfigForDeploy } from "@/domain/workflow"; +import { type WorkflowNodeConfigForBizDeploy, defaultNodeConfigForBizDeploy } from "@/domain/workflow"; import { useAntdForm } from "@/hooks"; import { getAllPreviousNodes } from "../_util"; @@ -127,7 +127,7 @@ const BizDeployNodeConfigForm = ({ node, ...props }: BizDeployNodeConfigFormProp const { token: themeToken } = theme.useToken(); const initialValues = useMemo(() => { - return getNodeForm(node)?.getValueIn("config") as WorkflowNodeConfigForDeploy | undefined; + return getNodeForm(node)?.getValueIn("config") as WorkflowNodeConfigForBizDeploy | undefined; }, [node]); const formSchema = getSchema({ i18n }).superRefine((values, ctx) => { @@ -530,7 +530,7 @@ const getAnchorItems = ({ i18n = getI18n() }: { i18n?: ReturnType>> => { return { - ...defaultNodeConfigForDeploy(), + ...defaultNodeConfigForBizDeploy(), }; }; diff --git a/ui/src/components/workflow/designer/forms/BizMonitorNodeConfigForm.tsx b/ui/src/components/workflow/designer/forms/BizMonitorNodeConfigForm.tsx index 17d8f1b2b..fbfc20cb0 100644 --- a/ui/src/components/workflow/designer/forms/BizMonitorNodeConfigForm.tsx +++ b/ui/src/components/workflow/designer/forms/BizMonitorNodeConfigForm.tsx @@ -6,7 +6,7 @@ import { createSchemaFieldRule } from "antd-zod"; import { z } from "zod"; import Tips from "@/components/Tips"; -import { type WorkflowNodeConfigForMonitor, defaultNodeConfigForMonitor } from "@/domain/workflow"; +import { type WorkflowNodeConfigForBizMonitor, defaultNodeConfigForBizMonitor } from "@/domain/workflow"; import { useAntdForm } from "@/hooks"; import { validDomainName, validIPv4Address, validIPv6Address, validPortNumber } from "@/utils/validators"; @@ -26,7 +26,7 @@ const BizMonitorNodeConfigForm = ({ node, ...props }: BizMonitorNodeConfigFormPr const { i18n, t } = useTranslation(); const initialValues = useMemo(() => { - return getNodeForm(node)?.getValueIn("config") as WorkflowNodeConfigForMonitor | undefined; + return getNodeForm(node)?.getValueIn("config") as WorkflowNodeConfigForBizMonitor | undefined; }, [node]); const formSchema = getSchema({ i18n }); @@ -83,7 +83,7 @@ const getAnchorItems = ({ i18n = getI18n() }: { i18n?: ReturnType>> => { - return defaultNodeConfigForMonitor(); + return defaultNodeConfigForBizMonitor(); }; const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { diff --git a/ui/src/components/workflow/designer/forms/BizNotifyNodeConfigForm.tsx b/ui/src/components/workflow/designer/forms/BizNotifyNodeConfigForm.tsx index 259d6bf49..3d86d8d60 100644 --- a/ui/src/components/workflow/designer/forms/BizNotifyNodeConfigForm.tsx +++ b/ui/src/components/workflow/designer/forms/BizNotifyNodeConfigForm.tsx @@ -10,7 +10,7 @@ import AccessEditDrawer from "@/components/access/AccessEditDrawer"; import AccessSelect from "@/components/access/AccessSelect"; import NotificationProviderSelect from "@/components/provider/NotificationProviderSelect"; import { ACCESS_USAGES, NOTIFICATION_PROVIDERS, accessProvidersMap, notificationProvidersMap } from "@/domain/provider"; -import { type WorkflowNodeConfigForNotify, defaultNodeConfigForNotify } from "@/domain/workflow"; +import { type WorkflowNodeConfigForBizNotify, defaultNodeConfigForBizNotify } from "@/domain/workflow"; import { useAntdForm, useZustandShallowSelector } from "@/hooks"; import { useAccessesStore } from "@/stores/access"; @@ -38,7 +38,7 @@ const BizNotifyNodeConfigForm = ({ node, ...props }: BizNotifyNodeConfigFormProp const { accesses } = useAccessesStore(useZustandShallowSelector("accesses")); const initialValues = useMemo(() => { - return getNodeForm(node)?.getValueIn("config") as WorkflowNodeConfigForNotify | undefined; + return getNodeForm(node)?.getValueIn("config") as WorkflowNodeConfigForBizNotify | undefined; }, [node]); const formSchema = getSchema({ i18n }); @@ -117,6 +117,14 @@ const BizNotifyNodeConfigForm = ({ node, ...props }: BizNotifyNodeConfigFormProp +
+ +
+ + + {t("workflow_node.notify.form_anchor.channel.title")} + +
- - - - ); -}; - -export default memo(UnknownNode); diff --git a/ui/src/components/workflow/node/UploadNode.tsx b/ui/src/components/workflow/node/UploadNode.tsx deleted file mode 100644 index 38db12c62..000000000 --- a/ui/src/components/workflow/node/UploadNode.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { memo, useMemo, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Flex, Typography } from "antd"; -import { produce } from "immer"; - -import { type WorkflowNodeConfigForUpload, WorkflowNodeType } from "@/domain/workflow"; -import { useZustandShallowSelector } from "@/hooks"; -import { useWorkflowStore } from "@/stores/workflow"; - -import SharedNode, { type SharedNodeProps } from "./_SharedNode"; -import UploadNodeConfigForm, { type UploadNodeConfigFormInstance } from "./UploadNodeConfigForm"; - -export interface UploadNodeProps extends SharedNodeProps {} - -const UploadNode = ({ node, disabled }: UploadNodeProps) => { - if (node.type !== WorkflowNodeType.Upload) { - console.warn(`[certimate] current workflow node type is not: ${WorkflowNodeType.Upload}`); - } - - const { t } = useTranslation(); - - const { updateNode } = useWorkflowStore(useZustandShallowSelector(["updateNode"])); - - const formRef = useRef(null); - const [formPending, setFormPending] = useState(false); - const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForUpload; - - const [drawerOpen, setDrawerOpen] = useState(false); - - const wrappedEl = useMemo(() => { - if (node.type !== WorkflowNodeType.Upload) { - console.warn(`[certimate] current workflow node type is not: ${WorkflowNodeType.Upload}`); - } - - if (!node.validated) { - return {t("workflow_node.action.configure_node")}; - } - - const config = (node.config as WorkflowNodeConfigForUpload) ?? {}; - return ( - - {config.domains ?? ""} - - ); - }, [node]); - - const handleDrawerConfirm = async () => { - setFormPending(true); - try { - await formRef.current!.validateFields(); - } catch (err) { - setFormPending(false); - throw err; - } - - try { - const newValues = getFormValues(); - const newNode = produce(node, (draft) => { - draft.config = { - ...newValues, - }; - draft.validated = true; - }); - await updateNode(newNode); - } finally { - setFormPending(false); - } - }; - - return ( - <> - setDrawerOpen(true)}> - {wrappedEl} - - - setDrawerOpen(open)} - > - - - - ); -}; - -export default memo(UploadNode); diff --git a/ui/src/components/workflow/node/UploadNodeConfigForm.tsx b/ui/src/components/workflow/node/UploadNodeConfigForm.tsx deleted file mode 100644 index 1f435a8f9..000000000 --- a/ui/src/components/workflow/node/UploadNodeConfigForm.tsx +++ /dev/null @@ -1,150 +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 { validateCertificate, validatePrivateKey } from "@/api/certificates"; -import TextFileInput from "@/components/TextFileInput"; -import { type WorkflowNodeConfigForUpload, defaultNodeConfigForUpload } from "@/domain/workflow"; -import { useAntdForm } from "@/hooks"; -import { getErrMsg } from "@/utils/error"; - -type UploadNodeConfigFormFieldValues = Partial; - -export interface UploadNodeConfigFormProps { - className?: string; - style?: React.CSSProperties; - disabled?: boolean; - initialValues?: UploadNodeConfigFormFieldValues; - onValuesChange?: (values: UploadNodeConfigFormFieldValues) => void; -} - -export interface UploadNodeConfigFormInstance { - getFieldsValue: () => ReturnType["getFieldsValue"]>; - resetFields: FormInstance["resetFields"]; - validateFields: FormInstance["validateFields"]; -} - -const initFormModel = (): UploadNodeConfigFormFieldValues => { - return defaultNodeConfigForUpload(); -}; - -const UploadNodeConfigForm = forwardRef( - ({ className, style, disabled, initialValues, onValuesChange }, ref) => { - const { t } = useTranslation(); - - const formSchema = z.object({ - domains: z.string().nullish(), - certificate: z - .string(t("workflow_node.upload.form.certificate.placeholder")) - .min(1, t("workflow_node.upload.form.certificate.placeholder")) - .max(20480, t("common.errmsg.string_max", { max: 20480 })), - privateKey: z - .string(t("workflow_node.upload.form.private_key.placeholder")) - .min(1, t("workflow_node.upload.form.private_key.placeholder")) - .max(20480, t("common.errmsg.string_max", { max: 20480 })), - }); - const formRule = createSchemaFieldRule(formSchema); - const { form: formInst, formProps } = useAntdForm({ - name: "workflowNodeUploadConfigForm", - initialValues: initialValues ?? initFormModel(), - }); - - const handleFormChange = (_: unknown, values: z.infer) => { - onValuesChange?.(values as UploadNodeConfigFormFieldValues); - }; - - useImperativeHandle(ref, () => { - return { - getFieldsValue: () => { - return formInst.getFieldsValue(true); - }, - resetFields: (fields) => { - return formInst.resetFields(fields as (keyof UploadNodeConfigFormFieldValues)[]); - }, - validateFields: (nameList, config) => { - return formInst.validateFields(nameList, config); - }, - } as UploadNodeConfigFormInstance; - }); - - const handleCertificateChange = async (value: string) => { - try { - const resp = await validateCertificate(value); - formInst.setFields([ - { - name: "domains", - value: resp.data.domains, - }, - { - name: "certificate", - value: value, - }, - ]); - } catch (e) { - formInst.setFields([ - { - name: "domains", - value: "", - }, - { - name: "certificate", - value: value, - errors: [getErrMsg(e)], - }, - ]); - } - - onValuesChange?.(formInst.getFieldsValue(true)); - }; - - const handlePrivateKeyChange = async (value: string) => { - try { - await validatePrivateKey(value); - formInst.setFields([ - { - name: "privateKey", - value: value, - }, - ]); - } catch (e) { - formInst.setFields([ - { - name: "privateKey", - value: value, - errors: [getErrMsg(e)], - }, - ]); - } - - onValuesChange?.(formInst.getFieldsValue(true)); - }; - - return ( -
- - - - - - - - - - - -
- ); - } -); - -export default memo(UploadNodeConfigForm); diff --git a/ui/src/components/workflow/node/_SharedNode.tsx b/ui/src/components/workflow/node/_SharedNode.tsx deleted file mode 100644 index 5c7e853f3..000000000 --- a/ui/src/components/workflow/node/_SharedNode.tsx +++ /dev/null @@ -1,389 +0,0 @@ -import { useMemo, useRef } from "react"; -import { useTranslation } from "react-i18next"; -import { IconCopy, IconDotsVertical, IconLabel, IconTrashX } from "@tabler/icons-react"; -import { useControllableValue } from "ahooks"; -import { App, Button, Card, Drawer, Dropdown, Flex, Input, type InputRef, type MenuProps, Popover } from "antd"; -import { produce } from "immer"; -import { isEqual } from "radash"; - -import { type WorkflowNode, WorkflowNodeType } from "@/domain/workflow"; -import { useZustandShallowSelector } from "@/hooks"; -import { useWorkflowStore } from "@/stores/workflow"; - -import AddNode from "./AddNode"; - -export interface SharedNodeProps { - node: WorkflowNode; - disabled?: boolean; -} - -// #region Title -type SharedNodeTitleProps = SharedNodeProps & { - className?: string; - style?: React.CSSProperties; -}; - -const SharedNodeTitle = ({ className, style, node, disabled }: SharedNodeTitleProps) => { - const { updateNode } = useWorkflowStore(useZustandShallowSelector(["updateNode"])); - - const handleBlur = (e: React.FocusEvent) => { - const oldName = node.name; - const newName = e.target.innerText.replaceAll("\r", "").replaceAll("\n", "").trim().substring(0, 64) || oldName; - if (oldName === newName) { - return; - } - - updateNode( - produce(node, (draft) => { - draft.name = newName; - }) - ); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.code === "Enter") { - e.preventDefault(); - e.currentTarget.blur(); - } - }; - - return ( -
-
- {node.name} -
-
- ); -}; -// #endregion - -// #region Menu -type SharedNodeMenuProps = SharedNodeProps & { - branchId?: string; - branchIndex?: number; - menus?: Array<"rename" | "duplicate" | "remove">; - trigger: React.ReactNode; - afterUpdate?: () => void; - afterDelete?: () => void; -}; - -const isNodeBranchLike = (node: WorkflowNode) => { - return ( - node.type === WorkflowNodeType.Branch || - node.type === WorkflowNodeType.Condition || - node.type === WorkflowNodeType.ExecuteResultBranch || - node.type === WorkflowNodeType.ExecuteSuccess || - node.type === WorkflowNodeType.ExecuteFailure - ); -}; - -const isNodeUnduplicatable = (node: WorkflowNode) => { - return ( - node.type === WorkflowNodeType.Start || - node.type === WorkflowNodeType.End || - node.type === WorkflowNodeType.Branch || - node.type === WorkflowNodeType.ExecuteResultBranch || - node.type === WorkflowNodeType.ExecuteSuccess || - node.type === WorkflowNodeType.ExecuteFailure - ); -}; - -const isNodeUnremovable = (node: WorkflowNode) => { - return node.type === WorkflowNodeType.Start || node.type === WorkflowNodeType.End; -}; - -const SharedNodeMenu = ({ menus, trigger, node, disabled, branchId, branchIndex, afterUpdate, afterDelete }: SharedNodeMenuProps) => { - const { t } = useTranslation(); - - const { modal } = App.useApp(); - - const { duplicateNode, updateNode, removeNode, duplicateBranch, removeBranch } = useWorkflowStore( - useZustandShallowSelector(["duplicateNode", "updateNode", "removeNode", "duplicateBranch", "removeBranch"]) - ); - - const nameInputRef = useRef(null); - const nameRef = useRef(); - - const handleRenameConfirm = async () => { - const oldName = node.name; - const newName = nameRef.current?.replaceAll("\r", "")?.replaceAll("\n", "").trim()?.substring(0, 64) || oldName; - if (oldName === newName) { - return; - } - - await updateNode( - produce(node, (draft) => { - draft.name = newName; - }) - ); - - afterUpdate?.(); - }; - - const handleDuplicateClick = async () => { - if (isNodeBranchLike(node)) { - await duplicateBranch(branchId!, branchIndex!); - } else { - await duplicateNode(node); - } - }; - - const handleRemoveClick = async () => { - if (isNodeBranchLike(node)) { - await removeBranch(branchId!, branchIndex!); - } else { - await removeNode(node); - } - - afterDelete?.(); - }; - - const menuItems = useMemo(() => { - let temp = [ - { - key: "rename", - disabled: disabled, - label: isNodeBranchLike(node) ? t("workflow_node.action.rename_branch") : t("workflow_node.action.rename_node"), - icon: ( - - - - ), - onClick: () => { - nameRef.current = node.name; - - const dialog = modal.confirm({ - title: isNodeBranchLike(node) ? t("workflow_node.action.rename_branch") : t("workflow_node.action.rename_node"), - content: ( -
- (nameRef.current = e.target.value)} - onPressEnter={async () => { - await handleRenameConfirm(); - dialog.destroy(); - }} - /> -
- ), - icon: null, - okText: t("common.button.save"), - onOk: handleRenameConfirm, - }); - setTimeout(() => nameInputRef.current?.focus(), 1); - }, - }, - { - key: "duplicate", - disabled: disabled || isNodeUnduplicatable(node), - label: isNodeBranchLike(node) ? t("workflow_node.action.duplicate_branch") : t("workflow_node.action.duplicate_node"), - icon: ( - - - - ), - onClick: handleDuplicateClick, - }, - { - type: "divider", - }, - { - key: "remove", - disabled: disabled || isNodeUnremovable(node), - label: isNodeBranchLike(node) ? t("workflow_node.action.remove_branch") : t("workflow_node.action.remove_node"), - icon: ( - - - - ), - danger: true, - onClick: handleRemoveClick, - }, - ] satisfies MenuProps["items"]; - - if (menus) { - temp = temp.filter((item) => item.type === "divider" || menus.includes(item.key as "rename" | "remove")); - temp = temp.filter((item, index, array) => { - if (item.type !== "divider") return true; - return index === 0 || array[index - 1].type !== "divider"; - }); - if (temp[0]?.type === "divider") { - temp.shift(); - } - if (temp[temp.length - 1]?.type === "divider") { - temp.pop(); - } - } - - return temp; - }, [disabled, node]); - - return ( - - {trigger} - - ); -}; -// #endregion - -// #region Block -type SharedNodeBlockProps = SharedNodeProps & { - children: React.ReactNode; - onClick?: (e: React.MouseEvent) => void; -}; - -const SharedNodeBlock = ({ children, node, disabled, onClick }: SharedNodeBlockProps) => { - const handleNodeClick = (e: React.MouseEvent) => { - onClick?.(e); - }; - - return ( - <> - } variant="text" />} />} - placement="rightTop" - > -
- -
- -
- -
-
{children}
-
-
-
-
- - - - ); -}; -// #endregion - -// #region EditDrawer -type SharedNodeEditDrawerProps = SharedNodeProps & { - children: React.ReactNode; - footer?: boolean; - loading?: boolean; - open?: boolean; - pending?: boolean; - onOpenChange?: (open: boolean) => void; - onConfirm: () => void | Promise; - getConfigNewValues: () => NonNullable; // 用于获取节点配置的新值,以便在抽屉关闭前进行对比,决定是否提示保存 -}; - -const SharedNodeConfigDrawer = ({ - children, - node, - disabled, - footer = true, - loading, - pending, - onConfirm, - getConfigNewValues, - ...props -}: SharedNodeEditDrawerProps) => { - const { t } = useTranslation(); - - const { modal } = App.useApp(); - - const [open, setOpen] = useControllableValue(props, { - valuePropName: "open", - defaultValuePropName: "defaultOpen", - trigger: "onOpenChange", - }); - - const handleConfirmClick = async () => { - await onConfirm(); - setOpen(false); - }; - - const handleCancelClick = () => { - if (pending) return; - - setOpen(false); - }; - - const handleClose = () => { - if (pending) return; - - const oldValues = JSON.parse(JSON.stringify(node.config ?? {})); - const newValues = JSON.parse(JSON.stringify(getConfigNewValues())); - const changed = !isEqual(oldValues, {}) && !isEqual(oldValues, newValues); - - const { promise, resolve, reject } = Promise.withResolvers(); - if (changed) { - modal.confirm({ - title: t("common.text.operation_confirm"), - content: t("workflow.detail.design.unsaved_changes.confirm"), - onOk: () => resolve(void 0), - onCancel: () => reject(), - }); - } else { - resolve(void 0); - } - - promise.then(() => setOpen(false)); - }; - - return ( - } type="text" />} - afterDelete={() => { - setOpen(false); - }} - /> - } - footer={ - !!footer && ( - - - - - ) - } - loading={loading} - maskClosable={!pending} - open={open} - title={
{node.name}
} - width={720} - onClose={handleClose} - > - {children} -
- ); -}; -// #endregion - -export default { - Title: SharedNodeTitle, - Menu: SharedNodeMenu, - Block: SharedNodeBlock, - ConfigDrawer: SharedNodeConfigDrawer, -}; diff --git a/ui/src/domain/workflow.ts b/ui/src/domain/workflow.ts index 2ebbb8e2b..9ea0cade4 100644 --- a/ui/src/domain/workflow.ts +++ b/ui/src/domain/workflow.ts @@ -1,8 +1,7 @@ -import dayjs from "dayjs"; -import { Immer, produce } from "immer"; +import { getI18n } from "react-i18next"; +import { Immer } from "immer"; import { nanoid } from "nanoid"; -import i18n from "@/i18n"; import { type WorkflowRunModel } from "./workflowRun"; export interface WorkflowModel extends BaseModel { @@ -11,8 +10,13 @@ export interface WorkflowModel extends BaseModel { trigger: string; triggerCron?: string; enabled?: boolean; - content?: WorkflowNode; - draft?: WorkflowNode; + content?: { + nodes: WorkflowNode[]; + }; + hasContent?: boolean; + draft?: { + nodes: WorkflowNode[]; + }; hasDraft?: boolean; lastRunRef?: string; lastRunStatus?: string; @@ -30,106 +34,32 @@ export const WORKFLOW_TRIGGERS = Object.freeze({ export type WorkflowTriggerType = (typeof WORKFLOW_TRIGGERS)[keyof typeof WORKFLOW_TRIGGERS]; // #region Node -export enum WorkflowNodeType { - Start = "start", - End = "end", - Apply = "apply", - Upload = "upload", - Monitor = "monitor", - Deploy = "deploy", - Notify = "notify", - Branch = "branch", - Condition = "condition", - ExecuteResultBranch = "execute_result_branch", - ExecuteSuccess = "execute_success", - ExecuteFailure = "execute_failure", - Custom = "custom", -} +export const WORKFLOW_NODE_TYPES = Object.freeze({ + START: "start", + END: "end", + CONDITION: "condition", + BRANCHBLOCK: "branchBlock", + TRYCATCH: "tryCatch", + TRYBLOCK: "tryBlock", + CATCHBLOCK: "catchBlock", + BIZ_APPLY: "bizApply", + BIZ_UPLOAD: "bizUpload", + BIZ_MONITOR: "bizMonitor", + BIZ_DEPLOY: "bizDeploy", + BIZ_NOTIFY: "bizNotify", +} as const); -const workflowNodeTypeDefaultNames: Map = new Map([ - [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")], -]); - -const workflowNodeTypeDefaultInputs: Map = new Map([ - [WorkflowNodeType.Apply, []], - [WorkflowNodeType.Upload, []], - [WorkflowNodeType.Monitor, []], - [ - WorkflowNodeType.Deploy, - [ - { - name: "certificate", - type: "certificate", - required: true, - label: i18n.t("workflow.variables.type.certificate.label"), - }, - ], - ], - [WorkflowNodeType.Notify, []], -]); - -const workflowNodeTypeDefaultOutputs: Map = new Map([ - [ - WorkflowNodeType.Apply, - [ - { - name: "certificate", - type: "certificate", - required: true, - label: i18n.t("workflow.variables.type.certificate.label"), - }, - ], - ], - [ - WorkflowNodeType.Upload, - [ - { - name: "certificate", - type: "certificate", - required: true, - label: i18n.t("workflow.variables.type.certificate.label"), - }, - ], - ], - [ - WorkflowNodeType.Monitor, - [ - { - name: "certificate", - type: "certificate", - required: true, - label: i18n.t("workflow.variables.type.certificate.label"), - }, - ], - ], - [WorkflowNodeType.Deploy, []], - [WorkflowNodeType.Notify, []], -]); +export type WorkflowNodeType = (typeof WORKFLOW_NODE_TYPES)[keyof typeof WORKFLOW_NODE_TYPES]; export type WorkflowNode = { id: string; - name: string; type: WorkflowNodeType; - - config?: Record; - inputs?: WorkflowNodeIO[]; - outputs?: WorkflowNodeIO[]; - - next?: WorkflowNode; - branches?: WorkflowNode[]; - - validated?: boolean; + data: { + name?: string; + disabled?: boolean; + config?: Record; + }; + blocks?: WorkflowNode[]; }; export type WorkflowNodeConfigForStart = { @@ -143,7 +73,15 @@ export const defaultNodeConfigForStart = (): Partial }; }; -export type WorkflowNodeConfigForApply = { +export type WorkflowNodeConfigForBranchBlock = { + expression?: Expr; +}; + +export const defaultNodeConfigForBranchBlock = (): Partial => { + return {}; +}; + +export type WorkflowNodeConfigForBizApply = { domains: string; contactEmail: string; challengeType: string; @@ -163,7 +101,7 @@ export type WorkflowNodeConfigForApply = { skipBeforeExpiryDays: number; }; -export const defaultNodeConfigForApply = (): Partial => { +export const defaultNodeConfigForBizApply = (): Partial => { return { challengeType: "dns-01", keyAlgorithm: "RSA2048", @@ -171,25 +109,25 @@ export const defaultNodeConfigForApply = (): Partial }; }; -export type WorkflowNodeConfigForUpload = { +export type WorkflowNodeConfigForBizUpload = { certificateId: string; domains: string; certificate: string; privateKey: string; }; -export const defaultNodeConfigForUpload = (): Partial => { +export const defaultNodeConfigForBizUpload = (): Partial => { return {}; }; -export type WorkflowNodeConfigForMonitor = { +export type WorkflowNodeConfigForBizMonitor = { host: string; port: number; domain?: string; requestPath?: string; }; -export const defaultNodeConfigForMonitor = (): Partial => { +export const defaultNodeConfigForBizMonitor = (): Partial => { return { host: "", port: 443, @@ -197,9 +135,7 @@ export const defaultNodeConfigForMonitor = (): Partial => { +export const defaultNodeConfigForBizDeploy = (): Partial => { return { skipOnLastSucceeded: true, }; }; -export type WorkflowNodeConfigForNotify = { +export type WorkflowNodeConfigForBizNotify = { subject: string; message: string; provider: string; @@ -222,35 +158,219 @@ export type WorkflowNodeConfigForNotify = { skipOnAllPrevSkipped?: boolean; }; -export const defaultNodeConfigForNotify = (): Partial => { - return { - subject: "", - message: "", - }; -}; - -export type WorkflowNodeConfigForCondition = { - expression?: Expr; -}; - -export const defaultNodeConfigForCondition = (): Partial => { +export const defaultNodeConfigForBizNotify = (): Partial => { return {}; }; -export type WorkflowNodeConfigForBranch = never; - -export type WorkflowNodeConfigForEnd = never; - -export type WorkflowNodeIO = { - name: string; - type: string; - required: boolean; - label: string; - value?: string; - valueSelector?: WorkflowNodeIOValueSelector; +export const newNodeId = (): string => { + return nanoid(); }; -export type WorkflowNodeIOValueSelector = ExprValueSelector; +export const newNode = (type: WorkflowNodeType, { i18n = getI18n() }: { i18n?: ReturnType }): WorkflowNode => { + const { t } = i18n; + + switch (type) { + case WORKFLOW_NODE_TYPES.START: + return { + id: newNodeId(), + type: type, + data: { + name: t("workflow_node.start.default_name"), + config: defaultNodeConfigForStart(), + }, + }; + + case WORKFLOW_NODE_TYPES.END: + return { + id: newNodeId(), + type: type, + data: { + name: t("workflow_node.end.default_name"), + }, + }; + + case WORKFLOW_NODE_TYPES.CONDITION: { + const branch1 = newNode(WORKFLOW_NODE_TYPES.BRANCHBLOCK, { i18n }); + branch1.data.name = `${branch1.data.name} 1`; + const branch2 = newNode(WORKFLOW_NODE_TYPES.BRANCHBLOCK, { i18n }); + branch2.data.name = `${branch2.data.name} 2`; + + return { + id: newNodeId(), + type: type, + data: { + name: t("workflow_node.condition.default_name"), + config: defaultNodeConfigForBranchBlock(), + }, + blocks: [branch1, branch2], + }; + } + + case WORKFLOW_NODE_TYPES.BRANCHBLOCK: + return { + id: newNodeId(), + type: type, + blocks: [], + data: { + name: t("workflow_node.branch_block.default_name"), + config: defaultNodeConfigForBranchBlock(), + }, + }; + + case WORKFLOW_NODE_TYPES.TRYCATCH: + return { + id: newNodeId(), + type: type, + data: { + name: t("workflow_node.try_catch.default_name"), + }, + blocks: [newNode(WORKFLOW_NODE_TYPES.TRYBLOCK, { i18n }), newNode(WORKFLOW_NODE_TYPES.CATCHBLOCK, { i18n })], + }; + + case WORKFLOW_NODE_TYPES.TRYBLOCK: + return { + id: newNodeId(), + type: type, + data: { + name: "", + }, + blocks: [], + }; + + case WORKFLOW_NODE_TYPES.CATCHBLOCK: + return { + id: newNodeId(), + type: type, + blocks: [newNode(WORKFLOW_NODE_TYPES.END, { i18n })], + data: { + name: t("workflow_node.catch_block.default_name"), + }, + }; + + case WORKFLOW_NODE_TYPES.BIZ_APPLY: + return { + id: newNodeId(), + type: type, + data: { + name: t("workflow_node.apply.default_name"), + config: defaultNodeConfigForBizApply(), + }, + }; + + case WORKFLOW_NODE_TYPES.BIZ_UPLOAD: + return { + id: newNodeId(), + type: type, + data: { + name: t("workflow_node.upload.default_name"), + config: defaultNodeConfigForBizUpload(), + }, + }; + + case WORKFLOW_NODE_TYPES.BIZ_MONITOR: + return { + id: newNodeId(), + type: type, + data: { + name: t("workflow_node.monitor.default_name"), + config: defaultNodeConfigForBizMonitor(), + }, + }; + + case WORKFLOW_NODE_TYPES.BIZ_DEPLOY: + return { + id: newNodeId(), + type: type, + data: { + name: t("workflow_node.deploy.default_name"), + config: defaultNodeConfigForBizDeploy(), + }, + }; + + case WORKFLOW_NODE_TYPES.BIZ_NOTIFY: + return { + id: newNodeId(), + type: type, + data: { + name: t("workflow_node.notify.default_name"), + config: defaultNodeConfigForBizNotify(), + }, + }; + + default: + throw new Error("Invalid value of `nodeType`"); + } +}; + +export const duplicateNode = (node: WorkflowNode, options?: { withCopySuffix?: boolean }) => { + const { produce } = new Immer({ autoFreeze: false }); + const deepClone = (node: WorkflowNode, { withCopySuffix, nodeIdMap }: { withCopySuffix: boolean; nodeIdMap: Map }) => { + return produce(node, (draft) => { + draft.data ??= {}; + draft.id = newNodeId(); + draft.data.name = withCopySuffix ? `${draft.data?.name || ""}-copy` : `${draft.data?.name || ""}`; + + nodeIdMap.set(node.id, draft.id); // 原节点 ID 映射到新节点 ID + + if (draft.blocks) { + draft.blocks = draft.blocks.map((block) => deepClone(block as WorkflowNode, { withCopySuffix: false, nodeIdMap })); + } + + if (draft.data?.config) { + switch (draft.type) { + case WORKFLOW_NODE_TYPES.BIZ_DEPLOY: + { + const prevNodeId = draft.data.config.certificateOutputNodeId as string; + if (nodeIdMap.has(prevNodeId)) { + draft.data.config = { + ...draft.data.config, + certificateOutputNodeId: nodeIdMap.get(prevNodeId), + }; + } + } + break; + + case WORKFLOW_NODE_TYPES.CONDITION: + { + const stack = [] as Expr[]; + const expr = draft.data.config.expression as Expr; + if (expr) { + stack.push(expr); + while (stack.length > 0) { + const n = stack.pop()!; + if ("left" in n) { + stack.push(n.left); + if ("selector" in n.left) { + const prevNodeId = n.left.selector.id; + if (nodeIdMap.has(prevNodeId)) { + n.left.selector.id = nodeIdMap.get(prevNodeId)!; + } + } + } + if ("right" in n) { + stack.push(n.right); + } + } + draft.data.config = { + ...draft.data.config, + expression: expr, + }; + } + } + break; + } + } + + return draft; + }); + }; + + return deepClone(node, { withCopySuffix: options?.withCopySuffix ?? true, nodeIdMap: new Map() }); +}; + +export const duplicateNodes = (nodes: WorkflowNode[], options?: { withCopySuffix?: boolean }) => { + return nodes.map((node) => duplicateNode(node, options)); +}; // #endregion // #region Expression @@ -280,606 +400,3 @@ export type LogicalExpr = { type: ExprType.Logical; operator: ExprLogicalOperato export type NotExpr = { type: ExprType.Not; expr: Expr }; export type Expr = ConstantExpr | VariantExpr | ComparisonExpr | LogicalExpr | NotExpr; // #endregion - -const isBranchNode = (node: WorkflowNode) => { - return node.type === WorkflowNodeType.Branch || node.type === WorkflowNodeType.ExecuteResultBranch; -}; - -type InitWorkflowOptions = { - template?: "standard" | "certtest"; -}; - -export const initWorkflow = (options: InitWorkflowOptions = {}): WorkflowModel => { - const root = newNode(WorkflowNodeType.Start, { - nodeConfig: { trigger: WORKFLOW_TRIGGERS.MANUAL }, - }); - - switch (options.template) { - case "standard": - { - let current = root; - - const applyNode = newNode(WorkflowNodeType.Apply, { - nodeConfig: defaultNodeConfigForApply(), - }); - current.next = applyNode; - - current = current.next; - current.next = newNode(WorkflowNodeType.ExecuteResultBranch); - - current = current.next!.branches![1]; - current.next = newNode(WorkflowNodeType.Notify, { - nodeConfig: { - ...defaultNodeConfigForNotify(), - 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: { - ...defaultNodeConfigForDeploy(), - certificate: `${applyNode.id}#certificate`, - } as WorkflowNodeConfigForDeploy, - }); - - current = current.next; - current.next = newNode(WorkflowNodeType.ExecuteResultBranch); - - current = current.next!.branches![1]; - current.next = newNode(WorkflowNodeType.Notify, { - nodeConfig: { - ...defaultNodeConfigForNotify(), - 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, { - nodeConfig: defaultNodeConfigForMonitor(), - }); - current.next = monitorNode; - - current = current.next; - current.next = newNode(WorkflowNodeType.ExecuteResultBranch); - - current = current.next!.branches![1]; - current.next = newNode(WorkflowNodeType.Notify, { - nodeConfig: { - ...defaultNodeConfigForNotify(), - 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: { - ...defaultNodeConfigForNotify(), - 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: { - ...defaultNodeConfigForNotify(), - subject: "[Certimate] Certificate Expiry Alert!", - message: "The certificate has already expired. Please pay attention to your website.", - } as WorkflowNodeConfigForNotify, - }); - } - break; - } - - return { - id: null!, - name: `MyWorkflow-${dayjs().format("YYYYMMDDHHmmss")}`, - trigger: root.config!.trigger as string, - triggerCron: root.config!.triggerCron as string, - enabled: false, - draft: root, - hasDraft: true, - created: new Date().toISOString(), - updated: new Date().toISOString(), - }; -}; - -type NewNodeOptions = { - nodeName?: string; - nodeConfig?: Record; - branchIndex?: number; -}; - -export const newNode = (nodeType: WorkflowNodeType, options: NewNodeOptions = {}): WorkflowNode => { - const nodeTypeName = workflowNodeTypeDefaultNames.get(nodeType) || ""; - const nodeName = options.branchIndex != null ? `${nodeTypeName} ${options.branchIndex + 1}` : nodeTypeName; - - const node: WorkflowNode = { - id: nanoid(), - name: options.nodeName ?? nodeName, - type: nodeType, - config: options.nodeConfig, - }; - - switch (nodeType) { - case WorkflowNodeType.Apply: - case WorkflowNodeType.Upload: - case WorkflowNodeType.Monitor: - case WorkflowNodeType.Deploy: - { - node.inputs = workflowNodeTypeDefaultInputs.get(nodeType); - node.outputs = workflowNodeTypeDefaultOutputs.get(nodeType); - } - break; - - case WorkflowNodeType.Condition: - { - node.validated = true; - } - break; - - case WorkflowNodeType.Branch: - { - node.branches = [newNode(WorkflowNodeType.Condition, { branchIndex: 0 }), newNode(WorkflowNodeType.Condition, { branchIndex: 1 })]; - } - break; - - case WorkflowNodeType.ExecuteResultBranch: - { - node.branches = [newNode(WorkflowNodeType.ExecuteSuccess), newNode(WorkflowNodeType.ExecuteFailure)]; - } - break; - - case WorkflowNodeType.ExecuteSuccess: - case WorkflowNodeType.ExecuteFailure: - { - node.validated = true; - } - break; - } - - return node; -}; - -type CloneNodeOptions = { - withCopySuffix?: boolean; - nodeIdMap?: Map; -}; - -export const cloneNode = (sourceNode: WorkflowNode, { withCopySuffix, nodeIdMap }: CloneNodeOptions = { withCopySuffix: true }): WorkflowNode => { - const { produce } = new Immer({ autoFreeze: false }); - const deepClone = (node: WorkflowNode): WorkflowNode => { - return produce(node, (draft) => { - draft.id = nanoid(); - - nodeIdMap ??= new Map(); // 原节点 ID 映射到新节点 ID - nodeIdMap.set(node.id, draft.id); - - if (draft.next) { - draft.next = cloneNode(draft.next, { withCopySuffix, nodeIdMap }); - } - - if (draft.branches) { - draft.branches = draft.branches.map((branch) => cloneNode(branch, { withCopySuffix, nodeIdMap })); - } - - if (draft.config) { - switch (draft.type) { - case WorkflowNodeType.Deploy: - { - const prevNodeId = (draft.config as WorkflowNodeConfigForDeploy).certificate?.split("#")?.[0]; - if (nodeIdMap.has(prevNodeId)) { - draft.config = { - ...draft.config, - certificate: `${nodeIdMap.get(prevNodeId)}#certificate`, - } as WorkflowNodeConfigForDeploy; - } - } - break; - - case WorkflowNodeType.Condition: - { - const stack = [] as Expr[]; - - const expr = (draft.config as WorkflowNodeConfigForCondition).expression; - if (expr) { - stack.push(expr); - - while (stack.length > 0) { - const n = stack.pop()!; - - if ("left" in n) { - stack.push(n.left); - - if ("selector" in n.left) { - const prevNodeId = n.left.selector.id; - if (nodeIdMap.has(prevNodeId)) { - n.left.selector.id = nodeIdMap.get(prevNodeId)!; - } - } - } - - if ("right" in n) { - stack.push(n.right); - } - } - - draft.config = { - ...draft.config, - expression: expr, - } as WorkflowNodeConfigForCondition; - } - } - break; - } - } - - return draft; - }); - }; - - const copyNode = produce(sourceNode, (draft) => { - draft.name = withCopySuffix ? `${draft.name}-copy` : draft.name; - }); - return deepClone(copyNode); -}; - -export const addNode = (root: WorkflowNode, targetNode: WorkflowNode, previousNodeId: string) => { - return produce(root, (draft) => { - let current = draft; - while (current) { - if (current.id === previousNodeId && !isBranchNode(targetNode)) { - targetNode.next = current.next; - current.next = targetNode; - break; - } else if (current.id === previousNodeId && isBranchNode(targetNode)) { - targetNode.branches![0].next = current.next; - current.next = targetNode; - break; - } - - if (isBranchNode(current)) { - current.branches ??= []; - current.branches = current.branches.map((branch) => addNode(branch, targetNode, previousNodeId)); - } - - current = current.next as WorkflowNode; - } - - return draft; - }); -}; - -export const duplicateNode = (root: WorkflowNode, targetNode: WorkflowNode) => { - if (isBranchNode(targetNode)) { - throw new Error("Cannot duplicate a branch node directly. Use `duplicateBranch` instead."); - } - - const copiedNode = cloneNode(targetNode); - return addNode(root, copiedNode, targetNode.id); -}; - -export const updateNode = (root: WorkflowNode, targetNode: WorkflowNode) => { - if (isBranchNode(targetNode)) { - throw new Error("Cannot update a branch node directly. Use `updateBranch` instead."); - } - - return produce(root, (draft) => { - let current = draft; - while (current) { - if (current.id === targetNode.id) { - // Object.assign(current, targetNode); - // TODO: 暂时这么处理,避免 #485 #489,后续再优化 - current.type = targetNode.type; - current.name = targetNode.name; - current.config = targetNode.config; - current.inputs = targetNode.inputs; - current.outputs = targetNode.outputs; - current.next = targetNode.next; - current.branches = targetNode.branches; - current.validated = targetNode.validated; - break; - } - - if (isBranchNode(current)) { - current.branches ??= []; - current.branches = current.branches.map((branch) => updateNode(branch, targetNode)); - } - - current = current.next as WorkflowNode; - } - - return draft; - }); -}; - -export const removeNode = (root: WorkflowNode, targetNodeId: string) => { - return produce(root, (draft) => { - let current = draft; - while (current) { - if (current.next?.id === targetNodeId) { - current.next = current.next.next; - break; - } - - if (isBranchNode(current)) { - current.branches ??= []; - current.branches = current.branches.map((branch) => removeNode(branch, targetNodeId)); - } - - current = current.next as WorkflowNode; - } - - return draft; - }); -}; - -export const addBranch = (root: WorkflowNode, branchNodeId: string) => { - return produce(root, (draft) => { - let current = draft; - while (current) { - if (current.id === branchNodeId) { - if (current.type !== WorkflowNodeType.Branch) { - return draft; - } - - current.branches ??= []; - current.branches.push( - newNode(WorkflowNodeType.Condition, { - branchIndex: current.branches.length, - }) - ); - break; - } - - if (isBranchNode(current)) { - current.branches ??= []; - current.branches = current.branches.map((branch) => addBranch(branch, branchNodeId)); - } - - current = current.next as WorkflowNode; - } - - return draft; - }); -}; - -export const duplicateBranch = (root: WorkflowNode, branchNodeId: string, branchIndex: number) => { - return produce(root, (draft) => { - let current = draft; - let last: WorkflowNode | undefined = { - id: "", - name: "", - type: WorkflowNodeType.Start, - next: draft, - }; - while (current && last) { - if (current.id === branchNodeId) { - if (!isBranchNode(current)) { - return draft; - } - - current.branches ??= []; - current.branches.splice(branchIndex + 1, 0, cloneNode(current.branches[branchIndex])); - - break; - } - - if (isBranchNode(current)) { - current.branches ??= []; - current.branches = current.branches.map((branch) => duplicateBranch(branch, branchNodeId, branchIndex)); - } - - current = current.next as WorkflowNode; - last = last.next; - } - - return draft; - }); -}; - -export const removeBranch = (root: WorkflowNode, branchNodeId: string, branchIndex: number) => { - return produce(root, (draft) => { - let current = draft; - let last: WorkflowNode | undefined = { - id: "", - name: "", - type: WorkflowNodeType.Start, - next: draft, - }; - while (current && last) { - if (current.id === branchNodeId) { - if (!isBranchNode(current)) { - return draft; - } - - current.branches ??= []; - current.branches.splice(branchIndex, 1); - - // 如果仅剩一个分支,删除分支节点,将分支节点的下一个节点挂载到当前节点 - if (current.branches.length === 1) { - const branch = current.branches[0]; - if (branch.next) { - last.next = branch.next; - let lastNode: WorkflowNode | undefined = branch.next; - while (lastNode?.next) { - lastNode = lastNode.next; - } - lastNode.next = current.next; - } else { - last.next = current.next; - } - } - - break; - } - - if (isBranchNode(current)) { - current.branches ??= []; - current.branches = current.branches.map((branch) => removeBranch(branch, branchNodeId, branchIndex)); - } - - current = current.next as WorkflowNode; - last = last.next; - } - - return draft; - }); -}; - -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; - } - if (current.id === nodeId) { - return true; - } - - if (current.type !== WorkflowNodeType.Branch && current.outputs && current.outputs.some((io) => filter(io))) { - output.push({ - ...current, - outputs: current.outputs.filter((io) => filter(io)), - }); - } - - if (isBranchNode(current)) { - let currentLength = output.length; - const latestOutput = output.length > 0 ? output[output.length - 1] : null; - for (const branch of current.branches!) { - if (branch.type === WorkflowNodeType.ExecuteFailure) { - output.splice(output.length - 1); - currentLength -= 1; - } - if (traverse(branch, output)) { - return true; - } - // 如果当前分支没有输出,清空之前的输出 - if (output.length > currentLength) { - output.splice(currentLength); - } - if (latestOutput && branch.type === WorkflowNodeType.ExecuteFailure) { - output.push(latestOutput); - currentLength += 1; - } - } - } - - return traverse(current.next as WorkflowNode, output); - }; - - traverse(root, outputs); - return outputs; -}; - -export const isAllNodesValidated = (node: WorkflowNode): boolean => { - let current = node as typeof node | undefined; - while (current) { - if (isBranchNode(current)) { - for (const branch of current.branches!) { - if (!isAllNodesValidated(branch)) { - return false; - } - } - } else { - if (!current.validated) { - return false; - } - } - - current = current.next; - } - - return true; -}; diff --git a/ui/src/i18n/locales/en/nls.workflow.json b/ui/src/i18n/locales/en/nls.workflow.json index 5c5b934c7..605a612cc 100644 --- a/ui/src/i18n/locales/en/nls.workflow.json +++ b/ui/src/i18n/locales/en/nls.workflow.json @@ -19,7 +19,7 @@ "workflow.action.batch_delete.modal.title": "Delete workflows", "workflow.action.batch_delete.modal.content": "Are you sure want to delete these {{count}} selected workflows?
This action cannot be undone.", "workflow.action.enable.button": "Activate", - "workflow.action.enable.errmsg.uncompleted": "Please complete the orchestration and publish the changes first", + "workflow.action.enable.errmsg.unpublished": "Please complete the orchestration and publish the changes first", "workflow.action.disable.button": "Deactivate", "workflow.action.run.button": "Run", "workflow.action.run.menu": "Run workflow", diff --git a/ui/src/i18n/locales/en/nls.workflow.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json index bccd5232c..367377c92 100644 --- a/ui/src/i18n/locales/en/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/en/nls.workflow.nodes.json @@ -1,7 +1,7 @@ { + "workflow_node.kind.basis": "Basis", "workflow_node.kind.business": "Business", "workflow_node.kind.logic": "Logic", - "workflow_node.kind.common": "Common", "workflow_node.start.label": "Start", "workflow_node.start.help": "Define the trigger for workflow initiation. A workflow can only have one start node.", @@ -62,6 +62,8 @@ "workflow_node.apply.form.tencentcloud_eo_zone_id.label": "Tencent Cloud EdgeOne zone ID", "workflow_node.apply.form.tencentcloud_eo_zone_id.placeholder": "Please enter Tencent Cloud EdgeOne zone ID", "workflow_node.apply.form.tencentcloud_eo_zone_id.tooltip": "For more information, see https://console.tencentcloud.com/edgeone", + "workflow_node.apply.form.key_algorithm.label": "Certificate key algorithm", + "workflow_node.apply.form.key_algorithm.placeholder": "Please select certificate key algorithm", "workflow_node.apply.form.ca_provider.label": "Certificate authority (Optional)", "workflow_node.apply.form.ca_provider.placeholder": "Please select a certificate authority", "workflow_node.apply.form.ca_provider.tooltip": "Used to issue SSL certificates.", @@ -69,8 +71,6 @@ "workflow_node.apply.form.ca_provider_access.label": "Certificate authority credential", "workflow_node.apply.form.ca_provider_access.placeholder": "Please select an credential of the certificate authority", "workflow_node.apply.form.ca_provider_access.button": "Create", - "workflow_node.apply.form.key_algorithm.label": "Certificate key algorithm", - "workflow_node.apply.form.key_algorithm.placeholder": "Please select certificate key algorithm", "workflow_node.apply.form.acme_profile.label": "ACME certificate profile (Optional)", "workflow_node.apply.form.acme_profile.placeholder": "Please enter ACME certificate profile", "workflow_node.apply.form.acme_profile.tooltip": "It determines the ACME profile which will be used to affect issuance of the certificate requested. If you don't understand this option, just keep it by default.
Click here to learn more.", @@ -137,8 +137,8 @@ "workflow_node.deploy.form_anchor.deployment.title": "Deployment settings", "workflow_node.deploy.form_anchor.strategy.tab": "Strategy", "workflow_node.deploy.form_anchor.strategy.title": "Strategy settings", - "workflow_node.deploy.form.certificate_output_node_id.label": "Certificate reference node", - "workflow_node.deploy.form.certificate_output_node_id.placeholder": "Please select certificate reference node", + "workflow_node.deploy.form.certificate_output_node_id.label": "Certificate to deploy", + "workflow_node.deploy.form.certificate_output_node_id.placeholder": "Please select certificate to deploy", "workflow_node.deploy.form.certificate_output_node_id.help": "Notes: The certificate to be deployed comes from the previous nodes of application or upload.", "workflow_node.deploy.form.provider.label": "Deploy target", "workflow_node.deploy.form.provider.placeholder": "Please select deploy target", @@ -985,6 +985,8 @@ "workflow_node.notify.help": "Invoke the APIs of the service provider to push message notifications.", "workflow_node.notify.default_name": "Notification", "workflow_node.notify.form_anchor.parameters.tab": "Parameters", + "workflow_node.notify.form_anchor.channel.tab": "Channel", + "workflow_node.notify.form_anchor.channel.title": "Channel settings", "workflow_node.notify.form_anchor.strategy.tab": "Strategy", "workflow_node.notify.form_anchor.strategy.title": "Strategy settings", "workflow_node.notify.form.subject.label": "Subject", @@ -1067,48 +1069,5 @@ "workflow_node.end.label": "End", "workflow_node.end.help": "Suspend the workflow and exit. Usually the last node of a workflow.", - "workflow_node.end.default_name": "End", - - "@deprecated": "TODO: remove in v0.4.x below", - "workflow_node.action.configure_node": "Configure node", - "workflow_node.action.add_node": "Add node", - "workflow_node.action.rename_node": "Rename node", - "workflow_node.action.duplicate_node": "Duplicate node", - "workflow_node.action.remove_node": "Delete node", - "workflow_node.action.add_branch": "Add branch", - "workflow_node.action.rename_branch": "Rename branch", - "workflow_node.action.duplicate_branch": "Duplicate branch", - "workflow_node.action.remove_branch": "Delete branch", - "workflow_node.deploy.form.certificate.label": "Certificate", - "workflow_node.deploy.form.certificate.placeholder": "Please select certificate", - "workflow_node.deploy.form.certificate.help": "Notes: The certificate to be deployed comes from the previous nodes of application or upload.", - "workflow_node.branch.label": "Parallel/Conditional branch", - "workflow_node.branch.default_name": "Branch", - "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": "Branch", - "workflow_node.execute_success.label": "If the previous node succeeded ...", - "workflow_node.execute_success.default_name": "On Succeeded", - "workflow_node.execute_failure.label": "If the previous node failed ...", - "workflow_node.execute_failure.default_name": "On Failed" + "workflow_node.end.default_name": "End" } diff --git a/ui/src/i18n/locales/zh/nls.workflow.json b/ui/src/i18n/locales/zh/nls.workflow.json index 9bc0f507a..580d01a11 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.json +++ b/ui/src/i18n/locales/zh/nls.workflow.json @@ -19,7 +19,7 @@ "workflow.action.batch_delete.modal.title": "删除工作流", "workflow.action.batch_delete.modal.content": "确定要删除这 {{count}} 个被选中的工作流吗?
注意此操作不可撤销,请谨慎操作。", "workflow.action.enable.button": "启用", - "workflow.action.enable.errmsg.uncompleted": "请先完成流程编排并发布更改", + "workflow.action.enable.errmsg.unpublished": "请先完成流程编排并发布更改", "workflow.action.disable.button": "停用", "workflow.action.run.button": "运行", "workflow.action.run.menu": "运行工作流", diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index f143cd6ea..d512f08b8 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -1,7 +1,7 @@ { + "workflow_node.kind.basis": "基础", "workflow_node.kind.business": "业务", "workflow_node.kind.logic": "逻辑", - "workflow_node.kind.common": "常规", "workflow_node.start.label": "开始", "workflow_node.start.help": "定义工作流启动的触发条件。一个工作流只能有一个开始节点。", @@ -62,14 +62,14 @@ "workflow_node.apply.form.tencentcloud_eo_zone_id.label": "腾讯云 EdgeOne 站点 ID", "workflow_node.apply.form.tencentcloud_eo_zone_id.placeholder": "请输入腾讯云 EdgeOne 站点 ID", "workflow_node.apply.form.tencentcloud_eo_zone_id.tooltip": "这是什么?请参阅 https://console.cloud.tencent.com/edgeone", + "workflow_node.apply.form.key_algorithm.label": "证书算法", + "workflow_node.apply.form.key_algorithm.placeholder": "请选择证书算法", "workflow_node.apply.form.ca_provider.label": "证书颁发机构(可选)", "workflow_node.apply.form.ca_provider.placeholder": "请选择证书颁发机构", "workflow_node.apply.form.ca_provider.button": "设置", "workflow_node.apply.form.ca_provider_access.label": "证书颁发机构授权", "workflow_node.apply.form.ca_provider_access.placeholder": "请选择证书颁发机构授权", "workflow_node.apply.form.ca_provider_access.button": "新建", - "workflow_node.apply.form.key_algorithm.label": "证书算法", - "workflow_node.apply.form.key_algorithm.placeholder": "请选择证书算法", "workflow_node.apply.form.acme_profile.label": "ACME 证书配置(可选)", "workflow_node.apply.form.acme_profile.placeholder": "请输入 ACME 证书配置", "workflow_node.apply.form.acme_profile.tooltip": "表示证书颁发时使用的 ACME 证书配置。如果你不了解该选项的用途,保持默认即可。
点此了解更多。", @@ -136,8 +136,8 @@ "workflow_node.deploy.form_anchor.deployment.title": "部署设置", "workflow_node.deploy.form_anchor.strategy.tab": "执行策略", "workflow_node.deploy.form_anchor.strategy.title": "执行策略", - "workflow_node.deploy.form.certificate_output_node_id.label": "待部署证书节点", - "workflow_node.deploy.form.certificate_output_node_id.placeholder": "请选择待部署证书节点", + "workflow_node.deploy.form.certificate_output_node_id.label": "待部署证书", + "workflow_node.deploy.form.certificate_output_node_id.placeholder": "请选择待部署证书", "workflow_node.deploy.form.certificate_output_node_id.help": "提示:待部署证书来自之前的申请或上传节点,如果选项为空请先检查前序节点。", "workflow_node.deploy.form.provider.label": "部署目标", "workflow_node.deploy.form.provider.placeholder": "请选择部署目标", @@ -983,6 +983,8 @@ "workflow_node.notify.help": "调用服务提供商相关 API,将消息通知推送到指定的目标。", "workflow_node.notify.default_name": "通知", "workflow_node.notify.form_anchor.parameters.tab": "参数设置", + "workflow_node.notify.form_anchor.channel.tab": "渠道设置", + "workflow_node.notify.form_anchor.channel.title": "渠道设置", "workflow_node.notify.form_anchor.strategy.tab": "执行策略", "workflow_node.notify.form_anchor.strategy.title": "执行策略", "workflow_node.notify.form.subject.label": "通知主题", @@ -1024,8 +1026,8 @@ "workflow_node.condition.label": "并行/条件分支", "workflow_node.condition.help": "当满足指定的条件时,进入相应分支。某一分支中的节点执行失败不影响平行分支继续执行。", "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.default_name.template_certtest_on_expire_soon": "若网站证书即将过期…", + "workflow_node.condition.default_name.template_certtest_on_expired": "若网站证书已过期…", "workflow_node.branch_block.label": "分支", "workflow_node.branch_block.default_name": "分支", @@ -1065,48 +1067,5 @@ "workflow_node.end.label": "结束", "workflow_node.end.help": "中止工作流运行并退出。通常是一个工作流的最后一个节点。", - "workflow_node.end.default_name": "结束", - - "@deprecated": "TODO: remove in v0.4.x below", - "workflow_node.action.configure_node": "配置节点", - "workflow_node.action.add_node": "添加节点", - "workflow_node.action.rename_node": "重命名", - "workflow_node.action.duplicate_node": "复制节点", - "workflow_node.action.remove_node": "删除节点", - "workflow_node.action.add_branch": "添加分支", - "workflow_node.action.rename_branch": "重命名", - "workflow_node.action.duplicate_branch": "复制分支", - "workflow_node.action.remove_branch": "删除分支", - "workflow_node.deploy.form.certificate.label": "待部署证书", - "workflow_node.deploy.form.certificate.placeholder": "请选择待部署证书", - "workflow_node.deploy.form.certificate.help": "提示:待部署证书来自之前的申请或上传节点,如果选项为空请先检查前序节点。", - "workflow_node.branch.label": "并行/条件分支", - "workflow_node.branch.default_name": "分支", - "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": "分支", - "workflow_node.execute_success.label": "若上一节点执行成功…", - "workflow_node.execute_success.default_name": "执行成功", - "workflow_node.execute_failure.label": "若上一节点执行失败…", - "workflow_node.execute_failure.default_name": "执行失败" + "workflow_node.end.default_name": "结束" } diff --git a/ui/src/pages/workflows/WorkflowDetail.tsx b/ui/src/pages/workflows/WorkflowDetail.tsx index c404e11ec..416387fb6 100644 --- a/ui/src/pages/workflows/WorkflowDetail.tsx +++ b/ui/src/pages/workflows/WorkflowDetail.tsx @@ -7,7 +7,6 @@ import { App, Button, Input, type InputRef, Segmented, Skeleton } from "antd"; import { startRun as startWorkflowRun } from "@/api/workflows"; import Show from "@/components/Show"; -import { isAllNodesValidated } from "@/domain/workflow"; import { WORKFLOW_RUN_STATUSES } from "@/domain/workflowRun"; import { useZustandShallowSelector } from "@/hooks"; import { useWorkflowStore } from "@/stores/workflow"; @@ -36,7 +35,6 @@ const WorkflowDetail = () => { const divHeaderSize = useSize(divHeaderRef); const tabs = [ - ["design-legacy", "workflow.detail.design_legacy.tab", ], // TODO: 正式发布时移除 ["design", "workflow.detail.design.tab", ], ["runs", "workflow.detail.runs.tab", ], ] satisfies [string, string, React.ReactElement][]; @@ -94,8 +92,8 @@ const WorkflowDetail = () => { const handleActiveClick = async () => { try { - if (!workflow.enabled && (!workflow.content || !isAllNodesValidated(workflow.content))) { - message.warning(t("workflow.action.enable.errmsg.uncompleted")); + if (!workflow.enabled && !workflow.content) { + message.warning(t("workflow.action.enable.errmsg.unpublished")); return; } @@ -171,7 +169,7 @@ const WorkflowDetailBaseName = () => { const { notification } = App.useApp(); - const { workflow, initialized, setBaseInfo: setWorkflowBaseInfo } = useWorkflowStore(useZustandShallowSelector(["workflow", "initialized", "setBaseInfo"])); + const { workflow, initialized, ...workflowStore } = useWorkflowStore(useZustandShallowSelector(["workflow", "initialized", "setName"])); const inputRef = useRef(null); const [editing, setEditing] = useState(false); @@ -203,7 +201,7 @@ const WorkflowDetailBaseName = () => { setEditing(false); try { - await setWorkflowBaseInfo(value, workflow.description!); + await workflowStore.setName(value); } catch (err) { notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) }); @@ -249,7 +247,7 @@ const WorkflowDetailBaseDescription = () => { const { notification } = App.useApp(); - const { workflow, initialized, setBaseInfo: setWorkflowBaseInfo } = useWorkflowStore(useZustandShallowSelector(["workflow", "initialized", "setBaseInfo"])); + const { workflow, initialized, ...workflowStore } = useWorkflowStore(useZustandShallowSelector(["workflow", "initialized", "setDescription"])); const inputRef = useRef(null); const [editing, setEditing] = useState(false); @@ -281,7 +279,7 @@ const WorkflowDetailBaseDescription = () => { setEditing(false); try { - await setWorkflowBaseInfo(workflow.name!, value); + await workflowStore.setDescription(value); } catch (err) { notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) }); diff --git a/ui/src/pages/workflows/WorkflowDetailDesign.tsx b/ui/src/pages/workflows/WorkflowDetailDesign.tsx index a75fce156..632c3d59c 100644 --- a/ui/src/pages/workflows/WorkflowDetailDesign.tsx +++ b/ui/src/pages/workflows/WorkflowDetailDesign.tsx @@ -1,15 +1,12 @@ -import { useEffect, useRef, useState } from "react"; -import { getI18n, useTranslation } from "react-i18next"; -import { type FlowDocumentJSON } from "@flowgram.ai/document"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { IconArrowBackUp, IconDots } from "@tabler/icons-react"; import { useDeepCompareEffect } from "ahooks"; -import { Alert, App, Button, Card, Dropdown, Space, theme } from "antd"; -import { nanoid } from "nanoid"; -import { debounce, isEqual } from "radash"; +import { Alert, App, Button, Card, Dropdown, Result, Space, theme } from "antd"; +import { debounce } from "radash"; import Show from "@/components/Show"; import { WorkflowDesigner, type WorkflowDesignerInstance, WorkflowNodeDrawer, WorkflowToolbar } from "@/components/workflow/designer"; -import { type WorkflowNode, WorkflowNodeType } from "@/domain/workflow"; import { WORKFLOW_RUN_STATUSES } from "@/domain/workflowRun"; import { useZustandShallowSelector } from "@/hooks"; import { useWorkflowStore } from "@/stores/workflow"; @@ -21,47 +18,58 @@ const WorkflowDetailDesign = () => { const { token: themeToken } = theme.useToken(); const { message, modal, notification } = App.useApp(); - const { workflow } = useWorkflowStore(useZustandShallowSelector(["workflow", "publish", "rollback"])); - - const [isPendingOrRunning, setIsPendingOrRunning] = useState(false); - const [allowRollback, setAllowRollback] = useState(false); - const [allowPublish, setAllowPublish] = useState(false); + const { workflow, ...workflowStore } = useWorkflowStore(useZustandShallowSelector(["workflow", "setDraft", "publish", "rollback"])); + const [workflowRunDisabled, setWorkflowRunDisabled] = useState(false); + const workflowRollbackDisabled = useMemo( + () => workflowRunDisabled || !workflow.hasDraft || !workflow.hasContent, + [workflowRunDisabled, workflow.hasDraft, workflow.hasContent] + ); + const workflowPublishDisabled = useMemo(() => workflowRunDisabled || !workflow.hasDraft, [workflowRunDisabled, workflow.hasDraft]); useEffect(() => { - const pending = workflow.lastRunStatus === WORKFLOW_RUN_STATUSES.PENDING || workflow.lastRunStatus === WORKFLOW_RUN_STATUSES.RUNNING; - setIsPendingOrRunning(pending); - }, [workflow]); - - useEffect(() => { - const hasContent = !!workflow.content; - const hasChanges = workflow.hasDraft! || !isEqual(workflow.draft, workflow.content); - setAllowRollback(!isPendingOrRunning && hasContent && hasChanges); - setAllowPublish(!isPendingOrRunning && hasChanges); - }, [workflow.content, workflow.draft, workflow.hasDraft, isPendingOrRunning]); + const disabled = workflow.lastRunStatus === WORKFLOW_RUN_STATUSES.PENDING || workflow.lastRunStatus === WORKFLOW_RUN_STATUSES.RUNNING; + setWorkflowRunDisabled(disabled); + }, [workflow.lastRunStatus]); const designerRef = useRef(null); - const [degisnerData, setEditorData] = useState(); + const designerPending = useRef(false); // 保存中时阻止刷新画布 + const [designerError, setDesignerError] = useState(); useDeepCompareEffect(() => { - const data = { nodes: compactWorkflowDraft(workflow.draft) }; - designerRef.current?.document?.fromJSON(data); - setEditorData(data); + if (designerRef.current == null || designerRef.current.document.disposed) return; + if (designerPending.current) return; + + try { + const tree = workflow.draft ?? { nodes: [] }; + designerRef.current!.document.fromJSON(tree); + setDesignerError(void 0); + } catch (err) { + console.error(err); + setDesignerError(err); + } }, [workflow.draft]); - const onDesignerDocumentChange = debounce({ delay: 300 }, () => { - if (!designerRef.current || designerRef.current.document.disposed) return; + const handleDesignerDocumentChange = debounce({ delay: 300 }, async () => { + if (designerRef.current == null || designerRef.current.document.disposed) return; - console.log("document changed", designerRef.current!.document.toJSON()); + designerPending.current = true; + try { + const tree = designerRef.current!.document.toJSON(); + await workflowStore.setDraft(tree); + } catch (err) { + console.error(err); + notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) }); + } finally { + designerPending.current = false; + } }); - const { drawerProps: nodeDrawerProps, ...nodeDrawer } = WorkflowNodeDrawer.useDrawer(); - const handleRollbackClick = () => { modal.confirm({ title: t("workflow.detail.design.action.rollback.modal.title"), content: t("workflow.detail.design.action.rollback.modal.content"), onOk: async () => { try { - alert("TODO: rollback"); + await workflowStore.rollback(); message.success(t("common.text.operation_succeeded")); } catch (err) { @@ -83,7 +91,7 @@ const WorkflowDetailDesign = () => { content: t("workflow.detail.design.action.publish.modal.content"), onOk: async () => { try { - alert("TODO: publish"); + await workflowStore.publish(); message.success(t("common.text.operation_succeeded")); } catch (err) { @@ -94,6 +102,8 @@ const WorkflowDetailDesign = () => { }); }; + const { drawerProps: designerNodeDrawerProps, ...designerNodeDrawer } = WorkflowNodeDrawer.useDrawer(); + return (
{ }, }} > - { - nodeDrawer.open(node); - }} - > + designerNodeDrawer.open(node)}>
- - 该子页面仍在建设中,目前各节点数据只读,如需编辑请使用旧版编排工具。 -
- This subpage is under construction. All node data is read-only currently. Please use the legacy designer if you want to edit workflow. -
- } - showIcon - closable - type="warning" - />
@@ -137,7 +127,7 @@ const WorkflowDetailDesign = () => {
- { items: [ { key: "rollback", - disabled: !allowRollback, + disabled: workflowRollbackDisabled, label: t("workflow.detail.design.action.rollback.button"), icon: , onClick: handleRollbackClick, @@ -161,6 +151,7 @@ const WorkflowDetailDesign = () => {
+
@@ -174,199 +165,17 @@ const WorkflowDetailDesign = () => {
- + {designerError && ( +
+ +
+ )} + +
); }; -const compactWorkflowDraft = (root: WorkflowNode | undefined) => { - const { t } = getI18n(); - - // TODO: 仅为兼容适配 v0.3.x 数据,正式上线后待删除 - const res: FlowDocumentJSON["nodes"] = []; - - if (!root) { - res.push({ - id: nanoid(), - type: "start", - data: { - name: t("workflow_node.start.default_name"), - }, - }); - } else { - const convert = (node: WorkflowNode | undefined) => { - const temp: typeof res = []; - - let current: typeof node = node; - while (current) { - switch (current.type) { - case WorkflowNodeType.Start: - temp.push({ - id: current.id, - type: "start", - data: { - name: current.name, - config: current.config, - }, - }); - break; - - case WorkflowNodeType.Apply: - temp.push({ - id: current.id, - type: "bizApply", - data: { - name: current.name, - config: current.config, - inputs: current.inputs, - outputs: current.outputs, - }, - }); - break; - - case WorkflowNodeType.Upload: - temp.push({ - id: current.id, - type: "bizUpload", - data: { - name: current.name, - config: current.config, - inputs: current.inputs, - outputs: current.outputs, - }, - }); - break; - - case WorkflowNodeType.Monitor: - temp.push({ - id: current.id, - type: "bizMonitor", - data: { - name: current.name, - config: current.config, - inputs: current.inputs, - outputs: current.outputs, - }, - }); - break; - - case WorkflowNodeType.Deploy: - temp.push({ - id: current.id, - type: "bizDeploy", - data: { - name: current.name, - config: { - ...current.config, - certificate: undefined, - certificateOutputNodeId: (current.config?.certificate as string)?.split("#")?.shift(), - }, - inputs: current.inputs, - outputs: current.outputs, - }, - }); - break; - - case WorkflowNodeType.Notify: - temp.push({ - id: current.id, - type: "bizNotify", - data: { - name: current.name, - config: current.config, - inputs: current.inputs, - outputs: current.outputs, - }, - }); - break; - - case WorkflowNodeType.ExecuteResultBranch: { - const tryNode = temp.pop()!; - temp.push({ - id: current.id, - type: "tryCatch", - blocks: [ - { - id: current.branches?.find((b) => b.type === WorkflowNodeType.ExecuteSuccess)?.id || nanoid(), - type: "tryBlock", - blocks: [tryNode], - data: { - name: current.branches?.find((b) => b.type === WorkflowNodeType.ExecuteSuccess)?.name, - }, - }, - { - id: current.branches?.find((b) => b.type === WorkflowNodeType.ExecuteFailure)?.id || nanoid(), - type: "catchBlock", - blocks: [ - ...convert(current.branches?.find((b) => b.type === WorkflowNodeType.ExecuteFailure)?.next), - { - id: nanoid(), - type: "end", - data: { - name: t("workflow_node.end.default_name"), - }, - }, - ], - data: { - name: current.branches?.find((b) => b.type === WorkflowNodeType.ExecuteFailure)?.name, - }, - }, - ], - data: { - name: current.name, - config: current.config, - }, - }); - - current = current.branches?.find((b) => b.type === WorkflowNodeType.ExecuteSuccess); - break; - } - - case WorkflowNodeType.Branch: { - temp.push({ - id: current.id, - type: "condition", - blocks: - current.branches?.map((branch) => { - return { - id: branch.id, - type: "branchBlock", - blocks: convert(branch.next), - data: { - name: branch.name, - config: branch.config, - }, - }; - }) ?? [], - data: { - name: current.name, - config: current.config, - }, - }); - break; - } - } - - current = current?.next; - } - - return temp; - }; - - res.push(...convert(root)); - } - - res.push({ - id: nanoid(), - type: "end", - data: { - name: t("workflow_node.end.default_name"), - }, - }); - - return res; -}; - export default WorkflowDetailDesign; diff --git a/ui/src/pages/workflows/WorkflowDetailDesignLegacy.tsx b/ui/src/pages/workflows/WorkflowDetailDesignLegacy.tsx deleted file mode 100644 index 2f085f093..000000000 --- a/ui/src/pages/workflows/WorkflowDetailDesignLegacy.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { IconArrowBackUp, IconDots } from "@tabler/icons-react"; -import { Alert, App, Button, Card, Dropdown, Space } from "antd"; -import { isEqual } from "radash"; - -import Show from "@/components/Show"; -import WorkflowElementsContainer from "@/components/workflow/WorkflowElementsContainer"; -import { isAllNodesValidated } from "@/domain/workflow"; -import { WORKFLOW_RUN_STATUSES } from "@/domain/workflowRun"; -import { useZustandShallowSelector } from "@/hooks"; -import { useWorkflowStore } from "@/stores/workflow"; -import { getErrMsg } from "@/utils/error"; - -/** - * - * @deprecated - */ -const WorkflowDetailDesign = () => { - const { t } = useTranslation(); - - const { message, modal, notification } = App.useApp(); - - const { workflow, ...workflowState } = useWorkflowStore(useZustandShallowSelector(["workflow", "init", "publish", "rollback"])); - - const [isPendingOrRunning, setIsPendingOrRunning] = useState(false); - const [allowRollback, setAllowRollback] = useState(false); - const [allowPublish, setAllowPublish] = useState(false); - - useEffect(() => { - const pending = workflow.lastRunStatus === WORKFLOW_RUN_STATUSES.PENDING || workflow.lastRunStatus === WORKFLOW_RUN_STATUSES.RUNNING; - setIsPendingOrRunning(pending); - }, [workflow]); - - useEffect(() => { - const hasContent = !!workflow.content; - const hasChanges = workflow.hasDraft! || !isEqual(workflow.draft, workflow.content); - setAllowRollback(!isPendingOrRunning && hasContent && hasChanges); - setAllowPublish(!isPendingOrRunning && hasChanges); - }, [workflow.content, workflow.draft, workflow.hasDraft, isPendingOrRunning]); - - const handleRollbackClick = () => { - modal.confirm({ - title: t("workflow.detail.design.action.rollback.modal.title"), - content: t("workflow.detail.design.action.rollback.modal.content"), - onOk: async () => { - try { - await workflowState.rollback(); - - message.success(t("common.text.operation_succeeded")); - } catch (err) { - console.error(err); - notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) }); - } - }, - }); - }; - - const handlePublishClick = () => { - if (!isAllNodesValidated(workflow.draft!)) { - message.warning(t("workflow.detail.design.uncompleted_design.alert")); - return; - } - - modal.confirm({ - title: t("workflow.detail.design.action.publish.modal.title"), - content: t("workflow.detail.design.action.publish.modal.content"), - onOk: async () => { - try { - await workflowState.publish(); - - message.success(t("common.text.operation_succeeded")); - } catch (err) { - console.error(err); - notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) }); - } - }, - }); - }; - - return ( -
- -
-
-
- - 该子页面即将在 v0.4.0 中被移除。 -
- This subpage will be dropped in v0.4.0. -
- } - showIcon - closable - type="warning" - /> -
-
-
- - {t("workflow.detail.design.unpublished_draft.alert")}
} showIcon type="warning" /> - -
- - - , - onClick: handleRollbackClick, - }, - ], - }} - trigger={["click"]} - > -
-
-
-
- - -
- -
- ); -}; - -export default WorkflowDetailDesign; diff --git a/ui/src/pages/workflows/WorkflowList.tsx b/ui/src/pages/workflows/WorkflowList.tsx index bc48a7f4f..850c42b59 100644 --- a/ui/src/pages/workflows/WorkflowList.tsx +++ b/ui/src/pages/workflows/WorkflowList.tsx @@ -11,7 +11,7 @@ import { startRun as startWorkflowRun } from "@/api/workflows"; import Empty from "@/components/Empty"; import Show from "@/components/Show"; import WorkflowStatus from "@/components/workflow/WorkflowStatus"; -import { WORKFLOW_TRIGGERS, type WorkflowModel, cloneNode, initWorkflow, isAllNodesValidated } from "@/domain/workflow"; +import { WORKFLOW_TRIGGERS, type WorkflowModel, duplicateNodes } from "@/domain/workflow"; import { useAppSettings } from "@/hooks"; import { list as listWorkflows, remove as removeWorkflow, save as saveWorkflow } from "@/repository/workflow"; import { getErrMsg } from "@/utils/error"; @@ -164,6 +164,7 @@ const WorkflowList = () => { ), + disabled: !record.hasContent, onClick: () => { handleRecordRunClick(record); }, @@ -312,8 +313,8 @@ const WorkflowList = () => { const handleRecordActiveChange = async (workflow: WorkflowModel) => { try { - if (!workflow.enabled && (!workflow.content || !isAllNodesValidated(workflow.content))) { - message.warning(t("workflow.action.enable.errmsg.uncompleted")); + if (!workflow.enabled && !workflow.content) { + message.warning(t("workflow.action.enable.errmsg.unpublished")); return; } @@ -359,11 +360,7 @@ const WorkflowList = () => { description: workflow.description, trigger: workflow.trigger, triggerCron: workflow.triggerCron, - draft: workflow.content - ? cloneNode(workflow.content, { withCopySuffix: false }) - : workflow.draft - ? cloneNode(workflow.draft, { withCopySuffix: false }) - : initWorkflow().draft, + draft: { nodes: duplicateNodes(workflow.content?.nodes ?? workflow.draft?.nodes ?? [], { withCopySuffix: false }) }, hasDraft: true, } as WorkflowModel; const resp = await saveWorkflow(workflowCopy); diff --git a/ui/src/pages/workflows/WorkflowNew.tsx b/ui/src/pages/workflows/WorkflowNew.tsx index 8930a2ba7..58fa3faca 100644 --- a/ui/src/pages/workflows/WorkflowNew.tsx +++ b/ui/src/pages/workflows/WorkflowNew.tsx @@ -4,7 +4,14 @@ import { useNavigate } from "react-router-dom"; import { App, Card, Col, Row, Spin, Typography } from "antd"; import dayjs from "dayjs"; -import { type WorkflowModel, initWorkflow } from "@/domain/workflow"; +import { + WORKFLOW_NODE_TYPES, + type WorkflowModel, + type WorkflowNodeConfigForBizDeploy, + type WorkflowNodeConfigForBizNotify, + type WorkflowNodeConfigForBranchBlock, + newNode, +} from "@/domain/workflow"; import { save as saveWorkflow } from "@/repository/workflow"; import { getErrMsg } from "@/utils/error"; @@ -16,7 +23,7 @@ type TemplateKeys = typeof TEMPLATE_KEY_EMPTY | typeof TEMPLATE_KEY_CERTTEST | t const WorkflowNew = () => { const navigate = useNavigate(); - const { t } = useTranslation(); + const { i18n, t } = useTranslation(); const { notification } = App.useApp(); @@ -37,27 +44,169 @@ const WorkflowNew = () => { setTemplateSelectKey(key); try { - let workflow: WorkflowModel; + let workflow = {} as WorkflowModel; + workflow.name = t("workflow.new.templates.default_name"); + workflow.description = t("workflow.new.templates.default_description", { date: dayjs().format("YYYY-MM-DD HH:mm") }); + workflow.draft = { nodes: [] }; + workflow.hasDraft = true; switch (key) { case TEMPLATE_KEY_EMPTY: - workflow = initWorkflow(); + { + const startNode = newNode(WORKFLOW_NODE_TYPES.START, { i18n: i18n }); + const endNode = newNode(WORKFLOW_NODE_TYPES.END, { i18n: i18n }); + + workflow.draft!.nodes = [startNode, endNode]; + } break; case TEMPLATE_KEY_STANDARD: - workflow = initWorkflow({ template: "standard" }); + { + const startNode = newNode(WORKFLOW_NODE_TYPES.START, { i18n: i18n }); + const tryCatchNode = newNode(WORKFLOW_NODE_TYPES.TRYCATCH, { i18n: i18n }); + const applyNode = newNode(WORKFLOW_NODE_TYPES.BIZ_APPLY, { i18n: i18n }); + const deployNode = newNode(WORKFLOW_NODE_TYPES.BIZ_DEPLOY, { i18n: i18n }); + const notifyOnSuccessNode = newNode(WORKFLOW_NODE_TYPES.BIZ_NOTIFY, { i18n: i18n }); + const notifyOnFailureNode = newNode(WORKFLOW_NODE_TYPES.BIZ_NOTIFY, { i18n: i18n }); + const endNode = newNode(WORKFLOW_NODE_TYPES.END, { i18n: i18n }); + + deployNode.data.config = { + ...deployNode.data.config, + certificateOutputNodeId: applyNode.id, + } as WorkflowNodeConfigForBizDeploy; + + notifyOnSuccessNode.data.config = { + ...notifyOnSuccessNode.data.config, + subject: "[Certimate] Workflow Complete", + message: "Your workflow run has completed successfully.", + skipOnAllPrevSkipped: true, + } as WorkflowNodeConfigForBizNotify; + + notifyOnFailureNode.data.config = { + ...notifyOnFailureNode.data.config, + subject: "[Certimate] Workflow Failure Alert!", + message: "Your workflow run has failed. Please check the details.", + } as WorkflowNodeConfigForBizNotify; + + tryCatchNode.blocks!.at(0)!.blocks ??= []; + tryCatchNode.blocks!.at(0)!.blocks!.push(applyNode, deployNode, notifyOnSuccessNode); + tryCatchNode.blocks!.at(1)!.blocks ??= []; + tryCatchNode.blocks!.at(1)!.blocks!.unshift(notifyOnFailureNode); + + workflow.draft!.nodes = [startNode, tryCatchNode, endNode]; + } break; case TEMPLATE_KEY_CERTTEST: - workflow = initWorkflow({ template: "certtest" }); + { + const startNode = newNode(WORKFLOW_NODE_TYPES.START, { i18n: i18n }); + const tryCatchNode = newNode(WORKFLOW_NODE_TYPES.TRYCATCH, { i18n: i18n }); + const monitorNode = newNode(WORKFLOW_NODE_TYPES.BIZ_MONITOR, { i18n: i18n }); + const conditionNode = newNode(WORKFLOW_NODE_TYPES.CONDITION, { i18n: i18n }); + const notifyOnExpireSoonNode = newNode(WORKFLOW_NODE_TYPES.BIZ_NOTIFY, { i18n: i18n }); + const notifyOnExpiredNode = newNode(WORKFLOW_NODE_TYPES.BIZ_NOTIFY, { i18n: i18n }); + const notifyOnFailureNode = newNode(WORKFLOW_NODE_TYPES.BIZ_NOTIFY, { i18n: i18n }); + const endNode = newNode(WORKFLOW_NODE_TYPES.END, { i18n: i18n }); + + notifyOnExpireSoonNode.data.config = { + ...notifyOnExpireSoonNode.data.config, + subject: "[Certimate] Certificate Expiry Alert!", + message: "The certificate which you are monitoring will expire soon. Please pay attention to your website.", + } as WorkflowNodeConfigForBizNotify; + + notifyOnExpiredNode.data.config = { + ...notifyOnExpiredNode.data.config, + subject: "[Certimate] Certificate Expiry Alert!", + message: "The certificate which you are monitoring has already expired. Please pay attention to your website.", + } as WorkflowNodeConfigForBizNotify; + + notifyOnFailureNode.data.config = { + ...notifyOnFailureNode.data.config, + subject: "[Certimate] Workflow Failure Alert!", + message: "Your workflow run has failed. Please check the details.", + } as WorkflowNodeConfigForBizNotify; + + tryCatchNode.blocks!.at(0)!.blocks ??= []; + tryCatchNode.blocks!.at(0)!.blocks!.push(monitorNode, conditionNode); + tryCatchNode.blocks!.at(1)!.blocks ??= []; + tryCatchNode.blocks!.at(1)!.blocks!.unshift(notifyOnFailureNode); + + conditionNode.blocks!.at(0)!.data.name = t("workflow_node.condition.default_name.template_certtest_on_expire_soon"); + conditionNode.blocks!.at(0)!.data.config = { + ...conditionNode.blocks!.at(0)!.data.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 WorkflowNodeConfigForBranchBlock; + conditionNode.blocks!.at(0)!.blocks ??= []; + conditionNode.blocks!.at(0)!.blocks!.push(notifyOnExpireSoonNode); + conditionNode.blocks!.at(1)!.data.name = t("workflow_node.condition.default_name.template_certtest_on_expired"); + conditionNode.blocks!.at(1)!.data.config = { + ...conditionNode.blocks!.at(1)!.data.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 WorkflowNodeConfigForBranchBlock; + conditionNode.blocks!.at(1)!.blocks ??= []; + conditionNode.blocks!.at(1)!.blocks!.push(notifyOnExpiredNode); + + workflow.draft!.nodes = [startNode, tryCatchNode, endNode]; + } break; default: - throw "Invalid state: `templateSelectKey`"; + throw "Invalid value of `templateSelectKey`"; } - workflow.name = t("workflow.new.templates.default_name"); - workflow.description = t("workflow.new.templates.default_description", { date: dayjs().format("YYYY-MM-DD HH:mm") }); workflow = await saveWorkflow(workflow); navigate(`/workflows/${workflow.id}`, { replace: true }); } catch (err) { diff --git a/ui/src/routers/index.tsx b/ui/src/routers/index.tsx index 9ea8dd07a..5e192a1e7 100644 --- a/ui/src/routers/index.tsx +++ b/ui/src/routers/index.tsx @@ -15,7 +15,6 @@ import SettingsPersistence from "@/pages/settings/SettingsPersistence"; import SettingsSSLProvider from "@/pages/settings/SettingsSSLProvider"; import WorkflowDetail from "@/pages/workflows/WorkflowDetail"; import WorkflowDetailDesign from "@/pages/workflows/WorkflowDetailDesign"; -import WorkflowDetailDesignLegacy from "@/pages/workflows/WorkflowDetailDesignLegacy"; import WorkflowDetailRuns from "@/pages/workflows/WorkflowDetailRuns"; import WorkflowList from "@/pages/workflows/WorkflowList"; import WorkflowNew from "@/pages/workflows/WorkflowNew"; @@ -57,10 +56,6 @@ export const router = createHashRouter([ path: "/workflows/:id/design", element: , }, - { - path: "/workflows/:id/design-legacy", - element: , - }, { path: "/workflows/:id/runs", element: , diff --git a/ui/src/stores/workflow/index.ts b/ui/src/stores/workflow/index.ts index efcdbebf1..65dc99965 100644 --- a/ui/src/stores/workflow/index.ts +++ b/ui/src/stores/workflow/index.ts @@ -1,21 +1,11 @@ import { produce } from "immer"; +import { isEqual } from "radash"; import { create } from "zustand"; -import { - type WorkflowModel, - type WorkflowNodeConfigForStart, - addBranch, - addNode, - duplicateBranch, - duplicateNode, - getOutputBeforeNodeId, - removeBranch, - removeNode, - updateNode, -} from "@/domain/workflow"; +import { WORKFLOW_NODE_TYPES, type WorkflowModel, type WorkflowNodeConfigForStart } from "@/domain/workflow"; import { get as getWorkflow, save as saveWorkflow, subscribe as subscribeWorkflow } from "@/repository/workflow"; -import { type WorkflowState, type WorkflowStore } from "./types"; +import { type WorkflowStore } from "./types"; export const useWorkflowStore = create((set, get) => { const ensureInitialized = () => { @@ -54,19 +44,34 @@ export const useWorkflowStore = create((set, get) => { }); }, - setBaseInfo: async (name, description) => { + setName: async (name) => { ensureInitialized(); const resp = await saveWorkflow({ id: get().workflow.id!, name: name || "", - description: description || "", }); - set((state: WorkflowState) => { + set((state) => { return { workflow: produce(state.workflow, (draft) => { draft.name = resp.name; + }), + }; + }); + }, + + setDescription: async (description) => { + ensureInitialized(); + + const resp = await saveWorkflow({ + id: get().workflow.id!, + description: description || "", + }); + + set((state) => { + return { + workflow: produce(state.workflow, (draft) => { draft.description = resp.description; }), }; @@ -81,7 +86,7 @@ export const useWorkflowStore = create((set, get) => { enabled: enabled, }); - set((state: WorkflowState) => { + set((state) => { return { workflow: produce(state.workflow, (draft) => { draft.enabled = resp.enabled; @@ -90,26 +95,47 @@ export const useWorkflowStore = create((set, get) => { }); }, + setDraft: async (draft) => { + ensureInitialized(); + + const resp = await saveWorkflow({ + id: get().workflow.id!, + draft: draft, + hasDraft: !isEqual(draft, get().workflow.content), + }); + + set((state) => { + return { + workflow: produce(state.workflow, (draft) => { + draft.draft = resp.draft; + draft.hasDraft = resp.hasDraft; + }), + }; + }); + }, + publish: async () => { ensureInitialized(); - const root = get().workflow.draft!; - const startConfig = root.config as WorkflowNodeConfigForStart; + const tree = get().workflow.draft!; + if (tree?.nodes?.[0]?.type !== WORKFLOW_NODE_TYPES.START) throw "Workflow nodes tree of draft in invalid"; + const startConfig = tree.nodes[0].data.config as WorkflowNodeConfigForStart; const resp = await saveWorkflow({ id: get().workflow.id!, trigger: startConfig.trigger, triggerCron: startConfig.triggerCron, - content: root, + content: tree, + hasContent: true, hasDraft: false, }); - set((state: WorkflowState) => { + set((state) => { return { workflow: produce(state.workflow, (draft) => { draft.trigger = resp.trigger; draft.triggerCron = resp.triggerCron; draft.content = resp.content; - draft.draft = resp.draft; + draft.hasContent = resp.hasContent; draft.hasDraft = resp.hasDraft; }), }; @@ -119,171 +145,29 @@ export const useWorkflowStore = create((set, get) => { rollback: async () => { ensureInitialized(); - const root = get().workflow.content!; - const startConfig = root.config as WorkflowNodeConfigForStart; + const tree = get().workflow.content!; + if (tree?.nodes?.[0]?.type !== WORKFLOW_NODE_TYPES.START) throw "Workflow nodes tree of content in invalid"; + const startConfig = tree.nodes[0].data.config as WorkflowNodeConfigForStart; const resp = await saveWorkflow({ id: get().workflow.id!, - draft: root, - hasDraft: false, trigger: startConfig.trigger, triggerCron: startConfig.triggerCron, + hasContent: true, + draft: tree, + hasDraft: false, }); - set((state: WorkflowState) => { + set((state) => { return { workflow: produce(state.workflow, (draft) => { draft.trigger = resp.trigger; draft.triggerCron = resp.triggerCron; - draft.content = resp.content; + draft.hasContent = resp.hasContent; draft.draft = resp.draft; draft.hasDraft = resp.hasDraft; }), }; }); }, - - addNode: async (node, previousNodeId) => { - ensureInitialized(); - - const root = addNode(get().workflow.draft!, node, previousNodeId); - const resp = await saveWorkflow({ - id: get().workflow.id!, - draft: root, - hasDraft: true, - }); - - set((state: WorkflowState) => { - return { - workflow: produce(state.workflow, (draft) => { - draft.draft = resp.draft; - draft.hasDraft = resp.hasDraft; - }), - }; - }); - }, - - duplicateNode: async (node) => { - ensureInitialized(); - - const root = duplicateNode(get().workflow.draft!, node); - const resp = await saveWorkflow({ - id: get().workflow.id!, - draft: root, - hasDraft: true, - }); - - set((state: WorkflowState) => { - return { - workflow: produce(state.workflow, (draft) => { - draft.draft = resp.draft; - draft.hasDraft = resp.hasDraft; - }), - }; - }); - }, - - updateNode: async (node) => { - ensureInitialized(); - - const root = updateNode(get().workflow.draft!, node); - const resp = await saveWorkflow({ - id: get().workflow.id!, - draft: root, - hasDraft: true, - }); - - set((state: WorkflowState) => { - return { - workflow: produce(state.workflow, (draft) => { - draft.draft = resp.draft; - draft.hasDraft = resp.hasDraft; - }), - }; - }); - }, - - removeNode: async (node) => { - ensureInitialized(); - - const root = removeNode(get().workflow.draft!, node.id); - const resp = await saveWorkflow({ - id: get().workflow.id!, - draft: root, - hasDraft: true, - }); - - set((state: WorkflowState) => { - return { - workflow: produce(state.workflow, (draft) => { - draft.draft = resp.draft; - draft.hasDraft = resp.hasDraft; - }), - }; - }); - }, - - addBranch: async (branchId) => { - ensureInitialized(); - - const root = addBranch(get().workflow.draft!, branchId); - const resp = await saveWorkflow({ - id: get().workflow.id!, - draft: root, - hasDraft: true, - }); - - set((state: WorkflowState) => { - return { - workflow: produce(state.workflow, (draft) => { - draft.draft = resp.draft; - draft.hasDraft = resp.hasDraft; - }), - }; - }); - }, - - duplicateBranch: async (branchId, index) => { - ensureInitialized(); - - const root = duplicateBranch(get().workflow.draft!, branchId, index); - const resp = await saveWorkflow({ - id: get().workflow.id!, - draft: root, - hasDraft: true, - }); - - set((state: WorkflowState) => { - return { - workflow: produce(state.workflow, (draft) => { - draft.draft = resp.draft; - draft.hasDraft = resp.hasDraft; - }), - }; - }); - }, - - removeBranch: async (branchId, index) => { - ensureInitialized(); - - const root = removeBranch(get().workflow.draft!, branchId, index); - const resp = await saveWorkflow({ - id: get().workflow.id!, - draft: root, - hasDraft: true, - }); - - set((state: WorkflowState) => { - return { - workflow: produce(state.workflow, (draft) => { - draft.draft = resp.draft; - draft.hasDraft = resp.hasDraft; - }), - }; - }); - }, - - getWorkflowOuptutBeforeId: (nodeId, typeFilter) => { - return getOutputBeforeNodeId(get().workflow.draft!, nodeId, typeFilter); - }, }; }); diff --git a/ui/src/stores/workflow/types.ts b/ui/src/stores/workflow/types.ts index da85e6f1b..dc847a20a 100644 --- a/ui/src/stores/workflow/types.ts +++ b/ui/src/stores/workflow/types.ts @@ -1,4 +1,4 @@ -import { type WorkflowModel, type WorkflowNode } from "@/domain/workflow"; +import { type WorkflowModel } from "@/domain/workflow"; export interface WorkflowState { workflow: WorkflowModel; @@ -9,20 +9,13 @@ export interface WorkflowActions { init(id: string): void; destroy(): void; - setBaseInfo: (name: string, description: string) => void; - setEnabled(enabled: boolean): void; + setName: (name: Required["name"]) => void; + setDescription: (description: Required["description"]) => void; + setEnabled(enabled: Required["enabled"]): void; + setDraft(draft: Required["draft"]): void; + publish(): void; rollback(): void; - - addNode: (node: WorkflowNode, previousNodeId: string) => void; - duplicateNode: (node: WorkflowNode) => void; - updateNode: (node: WorkflowNode) => void; - removeNode: (node: WorkflowNode) => void; - addBranch: (branchId: string) => void; - duplicateBranch: (branchId: string, index: number) => void; - removeBranch: (branchId: string, index: number) => void; - - getWorkflowOuptutBeforeId: (nodeId: string, typeFilter?: string | string[]) => WorkflowNode[]; } export interface WorkflowStore extends WorkflowState, WorkflowActions {} From 79667c0d9fb7bc189b56a49cc90db60b0a241629 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Sat, 16 Aug 2025 20:49:35 +0800 Subject: [PATCH 05/20] feat(migration): workflow draft and content migrations --- internal/domain/workflow.go | 4 + internal/domain/workflow_output.go | 2 +- ...igrate.go => 1755187200_v0.4.0_migrate.go} | 437 +++++++++++++++++- 3 files changed, 426 insertions(+), 17 deletions(-) rename migrations/{1753272000_v0.4.0_migrate.go => 1755187200_v0.4.0_migrate.go} (55%) diff --git a/internal/domain/workflow.go b/internal/domain/workflow.go index 20048af27..42e68ce7e 100644 --- a/internal/domain/workflow.go +++ b/internal/domain/workflow.go @@ -49,6 +49,7 @@ const ( WorkflowTriggerTypeManual = WorkflowTriggerType("manual") ) +// Deprecated type WorkflowNode struct { Id string `json:"id"` Type WorkflowNodeType `json:"type"` @@ -198,6 +199,7 @@ func (n *WorkflowNode) GetConfigForCondition() WorkflowNodeConfigForCondition { } } +// Deprecated type WorkflowNodeIO struct { Label string `json:"label"` Name string `json:"name"` @@ -207,6 +209,8 @@ type WorkflowNodeIO struct { ValueSelector WorkflowNodeIOValueSelector `json:"valueSelector"` } +// Deprecated type WorkflowNodeIOValueSelector = expr.ExprValueSelector +// Deprecated const WorkflowNodeIONameCertificate string = "certificate" diff --git a/internal/domain/workflow_output.go b/internal/domain/workflow_output.go index 1ad97a3d9..b8bb12121 100644 --- a/internal/domain/workflow_output.go +++ b/internal/domain/workflow_output.go @@ -8,6 +8,6 @@ type WorkflowOutput struct { RunId string `json:"runId" db:"runRef"` NodeId string `json:"nodeId" db:"nodeId"` Node *WorkflowNode `json:"node" db:"node"` - Outputs []WorkflowNodeIO `json:"outputs" db:"outputs"` + Outputs []WorkflowNodeIO `json:"outputs" db:"outputs"` // Deprecated Succeeded bool `json:"succeeded" db:"succeeded"` } diff --git a/migrations/1753272000_v0.4.0_migrate.go b/migrations/1755187200_v0.4.0_migrate.go similarity index 55% rename from migrations/1753272000_v0.4.0_migrate.go rename to migrations/1755187200_v0.4.0_migrate.go index 4523c8ae5..ea76f3c02 100644 --- a/migrations/1753272000_v0.4.0_migrate.go +++ b/migrations/1755187200_v0.4.0_migrate.go @@ -2,9 +2,12 @@ package migrations import ( "encoding/json" + "regexp" + "strings" "github.com/pocketbase/pocketbase/core" m "github.com/pocketbase/pocketbase/migrations" + "github.com/samber/lo" ) func init() { @@ -12,7 +15,26 @@ func init() { tracer := NewTracer("v0.4.0") tracer.Printf("go ...") + // update collection `settings` + { + collection, err := app.FindCollectionByNameOrId("dy6ccjb60spfy6p") + if err != nil { + return err + } else if collection != nil { + if _, err := app.DB().NewQuery("DELETE FROM settings WHERE name = 'notifyChannels'").Execute(); err != nil { + return err + } + + if _, err := app.DB().NewQuery("DELETE FROM settings WHERE name = 'notifyTemplates'").Execute(); err != nil { + return err + } + + tracer.Printf("collection '%s' updated", collection.Name) + } + } + // update collection `access` + // - modify field `config` schema: rename property `defaultReceiver` to `receiver` { collection, err := app.FindCollectionByNameOrId("4yzbv8urny5ja1e") if err != nil { @@ -96,6 +118,13 @@ func init() { } // update collection `certificate` + // - modify field `source` candidates + // - rename field `effectAt` to `validityNotBefore` + // - rename field `expireAt` to `validityNotAfter` + // - rename field `workflowId` to `workflowRef` + // - rename field `workflowRunId` to `workflowRunRef` + // - rename field `workflowOutputId` to `workflowOutputRef` + // - rename field `workflowOutputId` to `workflowOutputRef` { collection, err := app.FindCollectionByNameOrId("4szxr9x43tpj6np") if err != nil { @@ -217,6 +246,9 @@ func init() { } // update collection `workflow` + // - modify field `trigger` candidates, and cascading migrate field `draft` / `content` + // - rename field `lastRunRefId` to `lastRunRef` + // - add field `hasContent` { collection, err := app.FindCollectionByNameOrId("tovyif5ax6j62ur") if err != nil { @@ -255,6 +287,18 @@ func init() { return err } + if err := collection.Fields.AddMarshaledJSONAt(7, []byte(`{ + "hidden": false, + "id": "bool3832150317", + "name": "hasContent", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }`)); err != nil { + return err + } + if err := app.Save(collection); err != nil { return err } @@ -263,6 +307,10 @@ func init() { return err } + if _, err := app.DB().NewQuery("UPDATE workflow SET hasContent = TRUE WHERE content IS NOT NULL").Execute(); err != nil { + return err + } + tracer.Printf("collection '%s' updated", collection.Name) records, err := app.FindAllRecords(collection) @@ -315,27 +363,13 @@ func init() { } // update collection `workflow_run` + // - modify field `trigger` candidates, and cascading migrate field `detail` + // - rename field `workflowId` to `workflowRef` { collection, err := app.FindCollectionByNameOrId("qjp8lygssgwyqyz") if err != nil { return err } else if collection != nil { - if err := collection.Fields.AddMarshaledJSONAt(1, []byte(`{ - "cascadeDelete": true, - "collectionId": "tovyif5ax6j62ur", - "hidden": false, - "id": "m8xfsyyy", - "maxSelect": 1, - "minSelect": 0, - "name": "workflowRef", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }`)); err != nil { - return err - } - if err := collection.Fields.AddMarshaledJSONAt(3, []byte(`{ "hidden": false, "id": "jlroa3fk", @@ -353,6 +387,22 @@ func init() { return err } + if err := collection.Fields.AddMarshaledJSONAt(1, []byte(`{ + "cascadeDelete": true, + "collectionId": "tovyif5ax6j62ur", + "hidden": false, + "id": "m8xfsyyy", + "maxSelect": 1, + "minSelect": 0, + "name": "workflowRef", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }`)); err != nil { + return err + } + if err := json.Unmarshal([]byte(`{ "indexes": [ "CREATE INDEX `+"`"+`idx_7ZpfjTFsD2`+"`"+` ON `+"`"+`workflow_run`+"`"+` (`+"`"+`workflowRef`+"`"+`)" @@ -406,6 +456,9 @@ func init() { } // update collection `workflow_output` + // - rename field `workflowId` to `workflowRef` + // - rename field `runId` to `runRef` + // - delete field `node` { collection, err := app.FindCollectionByNameOrId("bqnxb95f2cooowp") if err != nil { @@ -453,6 +506,10 @@ func init() { return err } + if field := collection.Fields.GetByName("node"); field != nil { + collection.Fields.RemoveById(field.GetId()) + } + if err := app.Save(collection); err != nil { return err } @@ -462,6 +519,10 @@ func init() { } // update collection `workflow_logs` + // - modify field `level` type + // - rename field `workflowId` to `workflowRef` + // - rename field `runId` to `runRef` + // - migrate field `message` { collection, err := app.FindCollectionByNameOrId("pbc_1682296116") if err != nil { @@ -565,6 +626,16 @@ func init() { return err } + if _, err := app.DB().NewQuery("UPDATE workflow_logs SET message = REPLACE(message, 'certificiate', 'certificate') WHERE level = 0").Execute(); err != nil { + return err + } + if _, err := app.DB().NewQuery("UPDATE workflow_logs SET message = REPLACE(message, 'ready to apply certificate', 'ready to request certificate') WHERE level = 0").Execute(); err != nil { + return err + } + if _, err := app.DB().NewQuery("UPDATE workflow_logs SET message = REPLACE(message, 'ready to obtain certificate', 'ready to request certificate') WHERE level = 0").Execute(); err != nil { + return err + } + if err := app.Save(collection); err != nil { return err } @@ -573,6 +644,340 @@ func init() { } } + // adapt to new workflow data structure + { + type dLegacyWorkflowNode struct { + Id string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Config map[string]any `json:"config,omitempty"` + Next *dLegacyWorkflowNode `json:"next,omitempty"` + Branches []*dLegacyWorkflowNode `json:"branches,omitempty"` + } + + type dWorkflowNodeData struct { + Name string `json:"name"` + Config map[string]any `json:"config,omitempty"` + } + + type dWorkflowNode struct { + Id string `json:"id"` + Type string `json:"type"` + Data dWorkflowNodeData `json:"data"` + Blocks []*dWorkflowNode `json:"blocks,omitempty"` + } + + convertNode := func(root *dLegacyWorkflowNode) []*dWorkflowNode { + lang := lo. + IfF(root == nil, func() string { return "zh" }). + ElseIf(regexp.MustCompile(`[\p{Han}]`).MatchString(root.Name), "zh"). + Else("en") + + var deepConvertNode func(node *dLegacyWorkflowNode) []*dWorkflowNode + deepConvertNode = func(node *dLegacyWorkflowNode) []*dWorkflowNode { + temp := make([]*dWorkflowNode, 0) + + current := node + for current != nil { + current.Config = lo.PickBy(current.Config, func(key string, value any) bool { + str, ok := value.(string) + return !ok || str != "" + }) + + switch current.Type { + case "start": + temp = append(temp, &dWorkflowNode{ + Id: current.Id, + Type: "start", + Data: dWorkflowNodeData{ + Name: current.Name, + Config: current.Config, + }, + }) + + case "apply", "upload", "monitor", "notify": + temp = append(temp, &dWorkflowNode{ + Id: current.Id, + Type: "biz" + strings.Title(current.Type), + Data: dWorkflowNodeData{ + Name: current.Name, + Config: current.Config, + }, + }) + + case "deploy": + if s, ok := current.Config["certificate"].(string); ok { + current.Config["certificateOutputNodeId"] = strings.Split(s, "#")[0] + delete(current.Config, "certificate") + } + + temp = append(temp, &dWorkflowNode{ + Id: current.Id, + Type: "bizDeploy", + Data: dWorkflowNodeData{ + Name: current.Name, + Config: current.Config, + }, + }) + + case "execute_result_branch": + if len(temp) == 0 { + break + } + + tryNode, _ := lo.Last(temp) + temp = lo.DropRight(temp, 1) + + branches := lo.GroupBy(current.Branches, func(b *dLegacyWorkflowNode) string { return b.Type }) + successBranch := lo.IfF(len(branches["execute_success"]) > 0, func() *dLegacyWorkflowNode { + return branches["execute_success"][0] + }).Else(nil) + failureBranch := lo.IfF(len(branches["execute_failure"]) > 0, func() *dLegacyWorkflowNode { + return branches["execute_failure"][0] + }).Else(nil) + successBranchId := lo.If(successBranch != nil, successBranch.Id).Else(core.GenerateDefaultRandomId()) + failureBranchId := lo.If(failureBranch != nil, failureBranch.Id).Else(core.GenerateDefaultRandomId()) + + catchBlocks := lo.If(failureBranch != nil && failureBranch.Next != nil, deepConvertNode(failureBranch.Next)).Else([]*dWorkflowNode{}) + catchBlocks = append(catchBlocks, &dWorkflowNode{ + Id: core.GenerateDefaultRandomId(), + Type: "end", + Data: dWorkflowNodeData{ + Name: lo.If(lang == "en", "End").Else("结束"), + }, + }) + + tryCatchNode := &dWorkflowNode{ + Id: current.Id, + Type: "tryCatch", + Data: dWorkflowNodeData{ + Name: lo.If(lang == "en", "Try to ...").Else("尝试执行…"), + Config: current.Config, + }, + Blocks: []*dWorkflowNode{ + { + Id: successBranchId, + Type: "tryBlock", + Data: dWorkflowNodeData{ + Name: "", + }, + Blocks: []*dWorkflowNode{tryNode}, + }, + { + Id: failureBranchId, + Type: "catchBlock", + Data: dWorkflowNodeData{ + Name: lo.If(lang == "en", "On failed ...").Else("若执行失败…"), + }, + Blocks: catchBlocks, + }, + }, + } + + temp = append(temp, tryCatchNode) + current = successBranch + + case "branch": + branchNode := &dWorkflowNode{ + Id: current.Id, + Type: "condition", + Data: dWorkflowNodeData{ + Name: lo.If(lang == "en", "Parallel").Else("并行"), + Config: current.Config, + }, + Blocks: lo.Map(current.Branches, func(b *dLegacyWorkflowNode, _ int) *dWorkflowNode { + return &dWorkflowNode{ + Id: b.Id, + Type: "branchBlock", + Data: dWorkflowNodeData{ + Name: b.Name, + Config: b.Config, + }, + Blocks: deepConvertNode(b.Next), + } + }), + } + + temp = append(temp, branchNode) + } + + if current != nil { + current = current.Next + } + } + + return temp + } + + nodes := lo.Ternary(root == nil, []*dWorkflowNode{ + { + Id: core.GenerateDefaultRandomId(), + Type: "start", + Data: dWorkflowNodeData{ + Name: lo.If(lang == "en", "Start").Else("开始"), + }, + }, + }, deepConvertNode(root)) + + return append(nodes, &dWorkflowNode{ + Id: core.GenerateDefaultRandomId(), + Type: "end", + Data: dWorkflowNodeData{ + Name: lo.If(lang == "en", "End").Else("结束"), + }, + }) + } + + // update collection `workflow` + // - migrate field `draft` / `content` + { + collection, err := app.FindCollectionByNameOrId("tovyif5ax6j62ur") + if err != nil { + return err + } else if collection != nil { + records, err := app.FindAllRecords(collection) + if err != nil { + return err + } else { + for _, record := range records { + changed := false + + draft := make(map[string]any) + if err := record.UnmarshalJSONField("draft", &draft); err == nil { + if len(draft) > 0 { + if _, ok := draft["nodes"]; !ok { + legacyRootNode := &dLegacyWorkflowNode{} + if err := record.UnmarshalJSONField("draft", legacyRootNode); err != nil { + return err + } else { + draft = make(map[string]any) + draft["nodes"] = convertNode(legacyRootNode) + record.Set("draft", draft) + changed = true + } + } + } + } + + content := make(map[string]any) + if err := record.UnmarshalJSONField("content", &content); err == nil { + if len(content) > 0 { + if _, ok := content["nodes"]; !ok { + legacyRootNode := &dLegacyWorkflowNode{} + if err := record.UnmarshalJSONField("content", legacyRootNode); err != nil { + return err + } else { + content = make(map[string]any) + content["nodes"] = convertNode(legacyRootNode) + record.Set("content", content) + record.Set("hasContent", true) + changed = true + } + } + } + } + + if changed { + if err := app.Save(record); err != nil { + return err + } + + tracer.Printf("record #%s in collection '%s' updated", record.Id, collection.Name) + } + } + } + } + } + + // update collection `workflow_run` + // - migrate field `detail` + { + collection, err := app.FindCollectionByNameOrId("qjp8lygssgwyqyz") + if err != nil { + return err + } else if collection != nil { + records, err := app.FindAllRecords(collection) + if err != nil { + return err + } else { + for _, record := range records { + changed := false + + detail := make(map[string]any) + if err := record.UnmarshalJSONField("detail", &detail); err == nil { + if len(detail) > 0 { + if _, ok := detail["nodes"]; !ok { + legacyRootNode := &dLegacyWorkflowNode{} + if err := record.UnmarshalJSONField("detail", legacyRootNode); err != nil { + return err + } else { + detail = make(map[string]any) + detail["nodes"] = convertNode(legacyRootNode) + record.Set("detail", detail) + changed = true + } + } + } + } + + if changed { + if err := app.Save(record); err != nil { + return err + } + + tracer.Printf("record #%s in collection '%s' updated", record.Id, collection.Name) + } + } + } + } + } + + // update collection `workflow_output` + // - migrate field `outputs` + { + collection, err := app.FindCollectionByNameOrId("bqnxb95f2cooowp") + if err != nil { + return err + } else if collection != nil { + records, err := app.FindAllRecords(collection) + if err != nil { + return err + } else { + for _, record := range records { + changed := false + + outputs := make([]map[string]any, 0) + if err := record.UnmarshalJSONField("outputs", &outputs); err == nil { + for i, output := range outputs { + if _, ok := output["label"]; ok { + output["valueType"] = "string" + delete(output, "label") + delete(output, "required") + delete(output, "valueSelector") + outputs[i] = output + } else { + continue + } + record.Set("outputs", outputs) + changed = true + } + } else { + println(err.Error()) + } + + if changed { + if err := app.Save(record); err != nil { + return err + } + + tracer.Printf("record #%s in collection '%s' updated", record.Id, collection.Name) + } + } + } + } + } + } + tracer.Printf("done") return nil }, func(app core.App) error { From dd7aa788fec504200aa1adb6cf76cd5e71786458 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Sat, 16 Aug 2025 21:23:20 +0800 Subject: [PATCH 06/20] refactor: rename some fields --- internal/domain/workflow.go | 5 +- internal/domain/workflow_run.go | 2 +- internal/repository/workflow.go | 18 +- internal/repository/workflow_run.go | 8 +- internal/workflow/service.go | 4 +- migrations/1755187200_v0.4.0_migrate.go | 170 +++++++++++------- ui/src/components/workflow/designer/_util.ts | 1 + ui/src/domain/workflow.ts | 14 +- ui/src/pages/workflows/WorkflowDetail.tsx | 4 +- .../pages/workflows/WorkflowDetailDesign.tsx | 12 +- ui/src/pages/workflows/WorkflowList.tsx | 4 +- ui/src/pages/workflows/WorkflowNew.tsx | 8 +- ui/src/stores/workflow/index.ts | 28 +-- ui/src/stores/workflow/types.ts | 4 +- 14 files changed, 164 insertions(+), 118 deletions(-) diff --git a/internal/domain/workflow.go b/internal/domain/workflow.go index 42e68ce7e..743122461 100644 --- a/internal/domain/workflow.go +++ b/internal/domain/workflow.go @@ -17,9 +17,10 @@ type Workflow struct { Trigger WorkflowTriggerType `json:"trigger" db:"trigger"` TriggerCron string `json:"triggerCron" db:"triggerCron"` Enabled bool `json:"enabled" db:"enabled"` - Content *WorkflowNode `json:"content" db:"content"` - Draft *WorkflowNode `json:"draft" db:"draft"` + GraphDraft *WorkflowNode `json:"graphDraft" db:"graphDraft"` + GraphContent *WorkflowNode `json:"graphContent" db:"graphContent"` HasDraft bool `json:"hasDraft" db:"hasDraft"` + HasContent bool `json:"hasContent" db:"hasContent"` LastRunId string `json:"lastRunId" db:"lastRunRef"` LastRunStatus WorkflowRunStatusType `json:"lastRunStatus" db:"lastRunStatus"` LastRunTime time.Time `json:"lastRunTime" db:"lastRunTime"` diff --git a/internal/domain/workflow_run.go b/internal/domain/workflow_run.go index f409aa107..ad771e465 100644 --- a/internal/domain/workflow_run.go +++ b/internal/domain/workflow_run.go @@ -13,7 +13,7 @@ type WorkflowRun struct { Trigger WorkflowTriggerType `json:"trigger" db:"trigger"` StartedAt time.Time `json:"startedAt" db:"startedAt"` EndedAt time.Time `json:"endedAt" db:"endedAt"` - Detail *WorkflowNode `json:"detail" db:"detail"` + Graph *WorkflowNode `json:"graph" db:"graph"` Error string `json:"error" db:"error"` } diff --git a/internal/repository/workflow.go b/internal/repository/workflow.go index a5c32a8b5..87a2e8bd0 100644 --- a/internal/repository/workflow.go +++ b/internal/repository/workflow.go @@ -79,9 +79,10 @@ func (r *WorkflowRepository) Save(ctx context.Context, workflow *domain.Workflow record.Set("trigger", string(workflow.Trigger)) record.Set("triggerCron", workflow.TriggerCron) record.Set("enabled", workflow.Enabled) - record.Set("content", workflow.Content) - record.Set("draft", workflow.Draft) + record.Set("graphDraft", workflow.GraphDraft) + record.Set("graphContent", workflow.GraphContent) record.Set("hasDraft", workflow.HasDraft) + record.Set("hasContent", workflow.HasContent) record.Set("lastRunRef", workflow.LastRunId) record.Set("lastRunStatus", string(workflow.LastRunStatus)) record.Set("lastRunTime", workflow.LastRunTime) @@ -100,13 +101,13 @@ func (r *WorkflowRepository) castRecordToModel(record *core.Record) (*domain.Wor return nil, fmt.Errorf("record is nil") } - content := &domain.WorkflowNode{} - if err := record.UnmarshalJSONField("content", content); err != nil { + graphDraft := &domain.WorkflowNode{} + if err := record.UnmarshalJSONField("graphDraft", graphDraft); err != nil { return nil, err } - draft := &domain.WorkflowNode{} - if err := record.UnmarshalJSONField("draft", draft); err != nil { + graphContent := &domain.WorkflowNode{} + if err := record.UnmarshalJSONField("graphContent", graphContent); err != nil { return nil, err } @@ -121,9 +122,10 @@ func (r *WorkflowRepository) castRecordToModel(record *core.Record) (*domain.Wor Trigger: domain.WorkflowTriggerType(record.GetString("trigger")), TriggerCron: record.GetString("triggerCron"), Enabled: record.GetBool("enabled"), - Content: content, - Draft: draft, + GraphDraft: graphDraft, + GraphContent: graphContent, HasDraft: record.GetBool("hasDraft"), + HasContent: record.GetBool("hasContent"), LastRunId: record.GetString("lastRunRef"), LastRunStatus: domain.WorkflowRunStatusType(record.GetString("lastRunStatus")), LastRunTime: record.GetDateTime("lastRunTime").Time(), diff --git a/internal/repository/workflow_run.go b/internal/repository/workflow_run.go index d128e9bcd..750c92a7b 100644 --- a/internal/repository/workflow_run.go +++ b/internal/repository/workflow_run.go @@ -55,7 +55,7 @@ func (r *WorkflowRunRepository) Save(ctx context.Context, workflowRun *domain.Wo record.Set("status", string(workflowRun.Status)) record.Set("startedAt", workflowRun.StartedAt) record.Set("endedAt", workflowRun.EndedAt) - record.Set("detail", workflowRun.Detail) + record.Set("graph", workflowRun.Graph) record.Set("error", workflowRun.Error) err = txApp.Save(record) if err != nil { @@ -125,8 +125,8 @@ func (r *WorkflowRunRepository) castRecordToModel(record *core.Record) (*domain. return nil, fmt.Errorf("record is nil") } - detail := &domain.WorkflowNode{} - if err := record.UnmarshalJSONField("detail", &detail); err != nil { + graph := &domain.WorkflowNode{} + if err := record.UnmarshalJSONField("graph", &graph); err != nil { return nil, err } @@ -141,7 +141,7 @@ func (r *WorkflowRunRepository) castRecordToModel(record *core.Record) (*domain. Trigger: domain.WorkflowTriggerType(record.GetString("trigger")), StartedAt: record.GetDateTime("startedAt").Time(), EndedAt: record.GetDateTime("endedAt").Time(), - Detail: detail, + Graph: graph, Error: record.GetString("error"), } return workflowRun, nil diff --git a/internal/workflow/service.go b/internal/workflow/service.go index d7cd29ef2..3635fd2dd 100644 --- a/internal/workflow/service.go +++ b/internal/workflow/service.go @@ -120,7 +120,7 @@ func (s *WorkflowService) StartRun(ctx context.Context, req *dtos.WorkflowStartR Status: domain.WorkflowRunStatusTypePending, Trigger: req.RunTrigger, StartedAt: time.Now(), - Detail: workflow.Content, + Graph: workflow.GraphContent, } if resp, err := s.workflowRunRepo.Save(ctx, run); err != nil { return err @@ -130,7 +130,7 @@ func (s *WorkflowService) StartRun(ctx context.Context, req *dtos.WorkflowStartR s.dispatcher.Dispatch(&dispatcher.WorkflowWorkerData{ WorkflowId: run.WorkflowId, - WorkflowContent: run.Detail, + WorkflowContent: run.Graph, RunId: run.Id, }) diff --git a/migrations/1755187200_v0.4.0_migrate.go b/migrations/1755187200_v0.4.0_migrate.go index ea76f3c02..41981d78d 100644 --- a/migrations/1755187200_v0.4.0_migrate.go +++ b/migrations/1755187200_v0.4.0_migrate.go @@ -246,8 +246,10 @@ func init() { } // update collection `workflow` - // - modify field `trigger` candidates, and cascading migrate field `draft` / `content` + // - modify field `trigger` candidates, and cascading migrate field `graphDraft` / `graphContent` // - rename field `lastRunRefId` to `lastRunRef` + // - rename field `draft` to `graphDraft` + // - rename field `content` to `graphContent` // - add field `hasContent` { collection, err := app.FindCollectionByNameOrId("tovyif5ax6j62ur") @@ -271,7 +273,45 @@ func init() { return err } + if err := collection.Fields.AddMarshaledJSONAt(6, []byte(`{ + "hidden": false, + "id": "g9ohkk5o", + "maxSize": 5000000, + "name": "graphDraft", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }`)); err != nil { + return err + } + + if err := collection.Fields.AddMarshaledJSONAt(7, []byte(`{ + "hidden": false, + "id": "awlphkfe", + "maxSize": 5000000, + "name": "graphContent", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }`)); err != nil { + return err + } + if err := collection.Fields.AddMarshaledJSONAt(9, []byte(`{ + "hidden": false, + "id": "bool3832150317", + "name": "hasContent", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }`)); err != nil { + return err + } + + if err := collection.Fields.AddMarshaledJSONAt(10, []byte(`{ "cascadeDelete": false, "collectionId": "qjp8lygssgwyqyz", "hidden": false, @@ -287,18 +327,6 @@ func init() { return err } - if err := collection.Fields.AddMarshaledJSONAt(7, []byte(`{ - "hidden": false, - "id": "bool3832150317", - "name": "hasContent", - "presentable": false, - "required": false, - "system": false, - "type": "bool" - }`)); err != nil { - return err - } - if err := app.Save(collection); err != nil { return err } @@ -307,7 +335,7 @@ func init() { return err } - if _, err := app.DB().NewQuery("UPDATE workflow SET hasContent = TRUE WHERE content IS NOT NULL").Execute(); err != nil { + if _, err := app.DB().NewQuery("UPDATE workflow SET hasContent = TRUE WHERE graphContent IS NOT NULL").Execute(); err != nil { return err } @@ -320,30 +348,30 @@ func init() { for _, record := range records { changed := false - draft := make(map[string]any) - if err := record.UnmarshalJSONField("draft", &draft); err == nil { - if _, ok := draft["config"]; ok { - config := draft["config"].(map[string]any) + graphDraft := make(map[string]any) + if err := record.UnmarshalJSONField("graphDraft", &graphDraft); err == nil { + if _, ok := graphDraft["config"]; ok { + config := graphDraft["config"].(map[string]any) if _, ok := config["trigger"]; ok { trigger := config["trigger"].(string) if trigger == "auto" { config["trigger"] = "scheduled" - record.Set("draft", draft) + record.Set("graphDraft", graphDraft) changed = true } } } } - content := make(map[string]any) - if err := record.UnmarshalJSONField("content", &content); err == nil { - if _, ok := content["config"]; ok { - config := content["config"].(map[string]any) + graphContent := make(map[string]any) + if err := record.UnmarshalJSONField("graphContent", &graphContent); err == nil { + if _, ok := graphContent["config"]; ok { + config := graphContent["config"].(map[string]any) if _, ok := config["trigger"]; ok { trigger := config["trigger"].(string) if trigger == "auto" { config["trigger"] = "scheduled" - record.Set("content", content) + record.Set("graphContent", graphContent) changed = true } } @@ -363,13 +391,30 @@ func init() { } // update collection `workflow_run` - // - modify field `trigger` candidates, and cascading migrate field `detail` + // - modify field `trigger` candidates, and cascading migrate field `graph` + // - rename field `detail` to `graph` // - rename field `workflowId` to `workflowRef` { collection, err := app.FindCollectionByNameOrId("qjp8lygssgwyqyz") if err != nil { return err } else if collection != nil { + if err := collection.Fields.AddMarshaledJSONAt(1, []byte(`{ + "cascadeDelete": true, + "collectionId": "tovyif5ax6j62ur", + "hidden": false, + "id": "m8xfsyyy", + "maxSelect": 1, + "minSelect": 0, + "name": "workflowRef", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }`)); err != nil { + return err + } + if err := collection.Fields.AddMarshaledJSONAt(3, []byte(`{ "hidden": false, "id": "jlroa3fk", @@ -387,18 +432,15 @@ func init() { return err } - if err := collection.Fields.AddMarshaledJSONAt(1, []byte(`{ - "cascadeDelete": true, - "collectionId": "tovyif5ax6j62ur", + if err := collection.Fields.AddMarshaledJSONAt(6, []byte(`{ "hidden": false, - "id": "m8xfsyyy", - "maxSelect": 1, - "minSelect": 0, - "name": "workflowRef", + "id": "json772177811", + "maxSize": 5000000, + "name": "graph", "presentable": false, "required": false, "system": false, - "type": "relation" + "type": "json" }`)); err != nil { return err } @@ -428,15 +470,15 @@ func init() { for _, record := range records { changed := false - detail := make(map[string]any) - if err := record.UnmarshalJSONField("detail", &detail); err == nil { - if _, ok := detail["config"]; ok { - config := detail["config"].(map[string]any) + graphContent := make(map[string]any) + if err := record.UnmarshalJSONField("graph", &graphContent); err == nil { + if _, ok := graphContent["config"]; ok { + config := graphContent["config"].(map[string]any) if _, ok := config["trigger"]; ok { trigger := config["trigger"].(string) if trigger == "auto" { config["trigger"] = "scheduled" - record.Set("detail", detail) + record.Set("graph", graphContent) changed = true } } @@ -829,7 +871,7 @@ func init() { } // update collection `workflow` - // - migrate field `draft` / `content` + // - migrate field `graphDraft` / `graphContent` { collection, err := app.FindCollectionByNameOrId("tovyif5ax6j62ur") if err != nil { @@ -842,34 +884,34 @@ func init() { for _, record := range records { changed := false - draft := make(map[string]any) - if err := record.UnmarshalJSONField("draft", &draft); err == nil { - if len(draft) > 0 { - if _, ok := draft["nodes"]; !ok { + graphDraft := make(map[string]any) + if err := record.UnmarshalJSONField("graphDraft", &graphDraft); err == nil { + if len(graphDraft) > 0 { + if _, ok := graphDraft["nodes"]; !ok { legacyRootNode := &dLegacyWorkflowNode{} - if err := record.UnmarshalJSONField("draft", legacyRootNode); err != nil { + if err := record.UnmarshalJSONField("graphDraft", legacyRootNode); err != nil { return err } else { - draft = make(map[string]any) - draft["nodes"] = convertNode(legacyRootNode) - record.Set("draft", draft) + graphDraft = make(map[string]any) + graphDraft["nodes"] = convertNode(legacyRootNode) + record.Set("graphDraft", graphDraft) changed = true } } } } - content := make(map[string]any) - if err := record.UnmarshalJSONField("content", &content); err == nil { - if len(content) > 0 { - if _, ok := content["nodes"]; !ok { + graphContent := make(map[string]any) + if err := record.UnmarshalJSONField("graphContent", &graphContent); err == nil { + if len(graphContent) > 0 { + if _, ok := graphContent["nodes"]; !ok { legacyRootNode := &dLegacyWorkflowNode{} - if err := record.UnmarshalJSONField("content", legacyRootNode); err != nil { + if err := record.UnmarshalJSONField("graphContent", legacyRootNode); err != nil { return err } else { - content = make(map[string]any) - content["nodes"] = convertNode(legacyRootNode) - record.Set("content", content) + graphContent = make(map[string]any) + graphContent["nodes"] = convertNode(legacyRootNode) + record.Set("graphContent", graphContent) record.Set("hasContent", true) changed = true } @@ -890,7 +932,7 @@ func init() { } // update collection `workflow_run` - // - migrate field `detail` + // - migrate field `graph` { collection, err := app.FindCollectionByNameOrId("qjp8lygssgwyqyz") if err != nil { @@ -903,17 +945,17 @@ func init() { for _, record := range records { changed := false - detail := make(map[string]any) - if err := record.UnmarshalJSONField("detail", &detail); err == nil { - if len(detail) > 0 { - if _, ok := detail["nodes"]; !ok { + graphContent := make(map[string]any) + if err := record.UnmarshalJSONField("graph", &graphContent); err == nil { + if len(graphContent) > 0 { + if _, ok := graphContent["nodes"]; !ok { legacyRootNode := &dLegacyWorkflowNode{} - if err := record.UnmarshalJSONField("detail", legacyRootNode); err != nil { + if err := record.UnmarshalJSONField("graph", legacyRootNode); err != nil { return err } else { - detail = make(map[string]any) - detail["nodes"] = convertNode(legacyRootNode) - record.Set("detail", detail) + graphContent = make(map[string]any) + graphContent["nodes"] = convertNode(legacyRootNode) + record.Set("graph", graphContent) changed = true } } diff --git a/ui/src/components/workflow/designer/_util.ts b/ui/src/components/workflow/designer/_util.ts index ab2bb5386..ca7c886b8 100644 --- a/ui/src/components/workflow/designer/_util.ts +++ b/ui/src/components/workflow/designer/_util.ts @@ -108,6 +108,7 @@ export const getAllPreviousNodes = (node: FlowNodeEntity): FlowNodeEntity[] => { return true; }); + // console.log(node.document.toString()); // console.log(node.document.root); // console.log(prevNodes); return prevNodes; diff --git a/ui/src/domain/workflow.ts b/ui/src/domain/workflow.ts index 9ea0cade4..a2cea54e4 100644 --- a/ui/src/domain/workflow.ts +++ b/ui/src/domain/workflow.ts @@ -10,14 +10,10 @@ export interface WorkflowModel extends BaseModel { trigger: string; triggerCron?: string; enabled?: boolean; - content?: { - nodes: WorkflowNode[]; - }; - hasContent?: boolean; - draft?: { - nodes: WorkflowNode[]; - }; + graphDraft?: WorkflowGraph; + graphContent?: WorkflowGraph; hasDraft?: boolean; + hasContent?: boolean; lastRunRef?: string; lastRunStatus?: string; lastRunTime?: string; @@ -26,6 +22,10 @@ export interface WorkflowModel extends BaseModel { }; } +export interface WorkflowGraph { + nodes: WorkflowNode[]; +} + export const WORKFLOW_TRIGGERS = Object.freeze({ SCHEDULED: "scheduled", MANUAL: "manual", diff --git a/ui/src/pages/workflows/WorkflowDetail.tsx b/ui/src/pages/workflows/WorkflowDetail.tsx index 416387fb6..84cc47160 100644 --- a/ui/src/pages/workflows/WorkflowDetail.tsx +++ b/ui/src/pages/workflows/WorkflowDetail.tsx @@ -54,7 +54,7 @@ const WorkflowDetail = () => { navigate(`/workflows/${workflowId}/${value}`); }; - const runButtonDisabled = useMemo(() => !workflow?.content, [workflow]); + const runButtonDisabled = useMemo(() => !workflow.hasContent, [workflow]); const [runButtonLoading, setRunButtonLoading] = useState(false); useEffect(() => { const pending = workflow.lastRunStatus === WORKFLOW_RUN_STATUSES.PENDING || workflow.lastRunStatus === WORKFLOW_RUN_STATUSES.RUNNING; @@ -92,7 +92,7 @@ const WorkflowDetail = () => { const handleActiveClick = async () => { try { - if (!workflow.enabled && !workflow.content) { + if (!workflow.enabled && !workflow.graphContent) { message.warning(t("workflow.action.enable.errmsg.unpublished")); return; } diff --git a/ui/src/pages/workflows/WorkflowDetailDesign.tsx b/ui/src/pages/workflows/WorkflowDetailDesign.tsx index 632c3d59c..27f1a3d41 100644 --- a/ui/src/pages/workflows/WorkflowDetailDesign.tsx +++ b/ui/src/pages/workflows/WorkflowDetailDesign.tsx @@ -18,7 +18,7 @@ const WorkflowDetailDesign = () => { const { token: themeToken } = theme.useToken(); const { message, modal, notification } = App.useApp(); - const { workflow, ...workflowStore } = useWorkflowStore(useZustandShallowSelector(["workflow", "setDraft", "publish", "rollback"])); + const { workflow, ...workflowStore } = useWorkflowStore(useZustandShallowSelector(["workflow", "orchestrate", "publish", "rollback"])); const [workflowRunDisabled, setWorkflowRunDisabled] = useState(false); const workflowRollbackDisabled = useMemo( @@ -39,22 +39,22 @@ const WorkflowDetailDesign = () => { if (designerPending.current) return; try { - const tree = workflow.draft ?? { nodes: [] }; - designerRef.current!.document.fromJSON(tree); + const graph = workflow.graphDraft ?? { nodes: [] }; + designerRef.current!.document.fromJSON(graph); setDesignerError(void 0); } catch (err) { console.error(err); setDesignerError(err); } - }, [workflow.draft]); + }, [workflow.graphDraft]); const handleDesignerDocumentChange = debounce({ delay: 300 }, async () => { if (designerRef.current == null || designerRef.current.document.disposed) return; designerPending.current = true; try { - const tree = designerRef.current!.document.toJSON(); - await workflowStore.setDraft(tree); + const graph = designerRef.current!.document.toJSON(); + await workflowStore.orchestrate(graph); } catch (err) { console.error(err); notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) }); diff --git a/ui/src/pages/workflows/WorkflowList.tsx b/ui/src/pages/workflows/WorkflowList.tsx index 850c42b59..59f4f6620 100644 --- a/ui/src/pages/workflows/WorkflowList.tsx +++ b/ui/src/pages/workflows/WorkflowList.tsx @@ -313,7 +313,7 @@ const WorkflowList = () => { const handleRecordActiveChange = async (workflow: WorkflowModel) => { try { - if (!workflow.enabled && !workflow.content) { + if (!workflow.enabled && !workflow.graphContent) { message.warning(t("workflow.action.enable.errmsg.unpublished")); return; } @@ -360,7 +360,7 @@ const WorkflowList = () => { description: workflow.description, trigger: workflow.trigger, triggerCron: workflow.triggerCron, - draft: { nodes: duplicateNodes(workflow.content?.nodes ?? workflow.draft?.nodes ?? [], { withCopySuffix: false }) }, + graphDraft: { nodes: duplicateNodes(workflow.graphContent?.nodes ?? workflow.graphDraft?.nodes ?? [], { withCopySuffix: false }) }, hasDraft: true, } as WorkflowModel; const resp = await saveWorkflow(workflowCopy); diff --git a/ui/src/pages/workflows/WorkflowNew.tsx b/ui/src/pages/workflows/WorkflowNew.tsx index 58fa3faca..18f23acef 100644 --- a/ui/src/pages/workflows/WorkflowNew.tsx +++ b/ui/src/pages/workflows/WorkflowNew.tsx @@ -47,7 +47,7 @@ const WorkflowNew = () => { let workflow = {} as WorkflowModel; workflow.name = t("workflow.new.templates.default_name"); workflow.description = t("workflow.new.templates.default_description", { date: dayjs().format("YYYY-MM-DD HH:mm") }); - workflow.draft = { nodes: [] }; + workflow.graphDraft = { nodes: [] }; workflow.hasDraft = true; switch (key) { @@ -56,7 +56,7 @@ const WorkflowNew = () => { const startNode = newNode(WORKFLOW_NODE_TYPES.START, { i18n: i18n }); const endNode = newNode(WORKFLOW_NODE_TYPES.END, { i18n: i18n }); - workflow.draft!.nodes = [startNode, endNode]; + workflow.graphDraft!.nodes = [startNode, endNode]; } break; @@ -93,7 +93,7 @@ const WorkflowNew = () => { tryCatchNode.blocks!.at(1)!.blocks ??= []; tryCatchNode.blocks!.at(1)!.blocks!.unshift(notifyOnFailureNode); - workflow.draft!.nodes = [startNode, tryCatchNode, endNode]; + workflow.graphDraft!.nodes = [startNode, tryCatchNode, endNode]; } break; @@ -199,7 +199,7 @@ const WorkflowNew = () => { conditionNode.blocks!.at(1)!.blocks ??= []; conditionNode.blocks!.at(1)!.blocks!.push(notifyOnExpiredNode); - workflow.draft!.nodes = [startNode, tryCatchNode, endNode]; + workflow.graphDraft!.nodes = [startNode, tryCatchNode, endNode]; } break; diff --git a/ui/src/stores/workflow/index.ts b/ui/src/stores/workflow/index.ts index 65dc99965..99b1fdc3c 100644 --- a/ui/src/stores/workflow/index.ts +++ b/ui/src/stores/workflow/index.ts @@ -95,19 +95,19 @@ export const useWorkflowStore = create((set, get) => { }); }, - setDraft: async (draft) => { + orchestrate: async (graph) => { ensureInitialized(); const resp = await saveWorkflow({ id: get().workflow.id!, - draft: draft, - hasDraft: !isEqual(draft, get().workflow.content), + graphDraft: graph, + hasDraft: !isEqual(graph, get().workflow.graphContent), }); set((state) => { return { workflow: produce(state.workflow, (draft) => { - draft.draft = resp.draft; + draft.graphDraft = resp.graphDraft; draft.hasDraft = resp.hasDraft; }), }; @@ -117,14 +117,14 @@ export const useWorkflowStore = create((set, get) => { publish: async () => { ensureInitialized(); - const tree = get().workflow.draft!; - if (tree?.nodes?.[0]?.type !== WORKFLOW_NODE_TYPES.START) throw "Workflow nodes tree of draft in invalid"; - const startConfig = tree.nodes[0].data.config as WorkflowNodeConfigForStart; + const graph = get().workflow.graphDraft!; + if (graph?.nodes?.[0]?.type !== WORKFLOW_NODE_TYPES.START) throw "Workflow nodes tree of draft in invalid"; + const startConfig = graph.nodes[0].data.config as WorkflowNodeConfigForStart; const resp = await saveWorkflow({ id: get().workflow.id!, trigger: startConfig.trigger, triggerCron: startConfig.triggerCron, - content: tree, + graphContent: graph, hasContent: true, hasDraft: false, }); @@ -134,7 +134,7 @@ export const useWorkflowStore = create((set, get) => { workflow: produce(state.workflow, (draft) => { draft.trigger = resp.trigger; draft.triggerCron = resp.triggerCron; - draft.content = resp.content; + draft.graphContent = resp.graphContent; draft.hasContent = resp.hasContent; draft.hasDraft = resp.hasDraft; }), @@ -145,15 +145,15 @@ export const useWorkflowStore = create((set, get) => { rollback: async () => { ensureInitialized(); - const tree = get().workflow.content!; - if (tree?.nodes?.[0]?.type !== WORKFLOW_NODE_TYPES.START) throw "Workflow nodes tree of content in invalid"; - const startConfig = tree.nodes[0].data.config as WorkflowNodeConfigForStart; + const graph = get().workflow.graphContent!; + if (graph?.nodes?.[0]?.type !== WORKFLOW_NODE_TYPES.START) throw "Workflow nodes tree of content in invalid"; + const startConfig = graph.nodes[0].data.config as WorkflowNodeConfigForStart; const resp = await saveWorkflow({ id: get().workflow.id!, trigger: startConfig.trigger, triggerCron: startConfig.triggerCron, hasContent: true, - draft: tree, + graphDraft: graph, hasDraft: false, }); @@ -163,7 +163,7 @@ export const useWorkflowStore = create((set, get) => { draft.trigger = resp.trigger; draft.triggerCron = resp.triggerCron; draft.hasContent = resp.hasContent; - draft.draft = resp.draft; + draft.graphDraft = resp.graphDraft; draft.hasDraft = resp.hasDraft; }), }; diff --git a/ui/src/stores/workflow/types.ts b/ui/src/stores/workflow/types.ts index dc847a20a..dc280282a 100644 --- a/ui/src/stores/workflow/types.ts +++ b/ui/src/stores/workflow/types.ts @@ -1,4 +1,4 @@ -import { type WorkflowModel } from "@/domain/workflow"; +import { type WorkflowGraph, type WorkflowModel } from "@/domain/workflow"; export interface WorkflowState { workflow: WorkflowModel; @@ -12,8 +12,8 @@ export interface WorkflowActions { setName: (name: Required["name"]) => void; setDescription: (description: Required["description"]) => void; setEnabled(enabled: Required["enabled"]): void; - setDraft(draft: Required["draft"]): void; + orchestrate(graph: WorkflowGraph): void; publish(): void; rollback(): void; } From 6c6b3dd512644fa9e1e7bdc1f57a91fd12fb77b3 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Sun, 17 Aug 2025 21:43:12 +0800 Subject: [PATCH 07/20] feat(ui): search by id --- ui/src/pages/accesses/AccessList.tsx | 2 +- ui/src/repository/certificate.ts | 2 +- ui/src/repository/workflow.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/src/pages/accesses/AccessList.tsx b/ui/src/pages/accesses/AccessList.tsx index 5fdccdb1d..4386ac8a4 100644 --- a/ui/src/pages/accesses/AccessList.tsx +++ b/ui/src/pages/accesses/AccessList.tsx @@ -205,7 +205,7 @@ const AccessList = () => { .filter((e) => { const keyword = (filters["keyword"] as string | undefined)?.trim(); if (keyword) { - return e.name.includes(keyword); + return e.id === keyword || e.name.includes(keyword); } return true; diff --git a/ui/src/repository/certificate.ts b/ui/src/repository/certificate.ts index da322d21e..ff14f28fa 100644 --- a/ui/src/repository/certificate.ts +++ b/ui/src/repository/certificate.ts @@ -16,7 +16,7 @@ export const list = async (request: ListRequest) => { const filters: string[] = ["deleted=null"]; if (request.keyword) { - filters.push(pb.filter("(subjectAltNames~{:keyword} || serialNumber={:keyword})", { keyword: request.keyword })); + filters.push(pb.filter("(id={:keyword} || serialNumber={:keyword} || subjectAltNames~{:keyword})", { keyword: request.keyword })); } if (request.state === "expireSoon") { filters.push(pb.filter("validityNotAfter<{:expiredAt} && validityNotAfter>@now", { expiredAt: dayjs().add(20, "d").toDate() })); diff --git a/ui/src/repository/workflow.ts b/ui/src/repository/workflow.ts index c7a931a2e..d1ef1a2cf 100644 --- a/ui/src/repository/workflow.ts +++ b/ui/src/repository/workflow.ts @@ -17,7 +17,7 @@ export const list = async (request: ListRequest) => { const filters: string[] = []; if (request.keyword) { - filters.push(pb.filter("name~{:keyword}", { keyword: request.keyword })); + filters.push(pb.filter("(id={:keyword} || name~{:keyword})", { keyword: request.keyword })); } if (request.enabled != null) { filters.push(pb.filter("enabled={:enabled}", { enabled: request.enabled })); From 089d66de04c1b6448d5dc98a898525447006d2ec Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Sun, 17 Aug 2025 21:44:30 +0800 Subject: [PATCH 08/20] refactor: rename some fields --- migrations/1755187200_v0.4.0_migrate.go | 68 ++++++++++++++++++- .../components/workflow/WorkflowRunDetail.tsx | 2 +- ui/src/components/workflow/WorkflowStatus.tsx | 6 +- ui/src/domain/workflowRun.ts | 2 +- ui/src/i18n/locales/en/nls.workflow.runs.json | 2 +- ui/src/i18n/locales/zh/nls.workflow.runs.json | 2 +- ui/src/pages/workflows/WorkflowDetail.tsx | 4 +- .../pages/workflows/WorkflowDetailDesign.tsx | 4 +- ui/src/pages/workflows/WorkflowDetailRuns.tsx | 8 +-- 9 files changed, 81 insertions(+), 17 deletions(-) diff --git a/migrations/1755187200_v0.4.0_migrate.go b/migrations/1755187200_v0.4.0_migrate.go index 41981d78d..de24b86b3 100644 --- a/migrations/1755187200_v0.4.0_migrate.go +++ b/migrations/1755187200_v0.4.0_migrate.go @@ -247,6 +247,7 @@ func init() { // update collection `workflow` // - modify field `trigger` candidates, and cascading migrate field `graphDraft` / `graphContent` + // - modify field `lastRunStatus` candidates // - rename field `lastRunRefId` to `lastRunRef` // - rename field `draft` to `graphDraft` // - rename field `content` to `graphContent` @@ -327,6 +328,26 @@ func init() { return err } + if err := collection.Fields.AddMarshaledJSONAt(11, []byte(`{ + "hidden": false, + "id": "zivdxh23", + "maxSelect": 1, + "name": "lastRunStatus", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "pending", + "processing", + "succeeded", + "failed", + "canceled" + ] + }`)); err != nil { + return err + } + if err := app.Save(collection); err != nil { return err } @@ -339,6 +360,10 @@ func init() { return err } + if _, err := app.DB().NewQuery("UPDATE workflow SET lastRunStatus = 'processing' WHERE lastRunStatus = 'running'").Execute(); err != nil { + return err + } + tracer.Printf("collection '%s' updated", collection.Name) records, err := app.FindAllRecords(collection) @@ -392,6 +417,7 @@ func init() { // update collection `workflow_run` // - modify field `trigger` candidates, and cascading migrate field `graph` + // - modify field `status` candidates // - rename field `detail` to `graph` // - rename field `workflowId` to `workflowRef` { @@ -415,6 +441,26 @@ func init() { return err } + if err := collection.Fields.AddMarshaledJSONAt(2, []byte(`{ + "hidden": false, + "id": "qldmh0tw", + "maxSelect": 1, + "name": "status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "pending", + "processing", + "succeeded", + "failed", + "canceled" + ] + }`)); err != nil { + return err + } + if err := collection.Fields.AddMarshaledJSONAt(3, []byte(`{ "hidden": false, "id": "jlroa3fk", @@ -461,6 +507,10 @@ func init() { return err } + if _, err := app.DB().NewQuery("UPDATE workflow_run SET status = 'processing' WHERE status = 'running'").Execute(); err != nil { + return err + } + tracer.Printf("collection '%s' updated", collection.Name) records, err := app.FindAllRecords(collection) @@ -737,10 +787,14 @@ func init() { }, }) - case "apply", "upload", "monitor", "notify": + case "apply": + if _, ok := current.Config["challengeType"].(string); !ok { + current.Config["challengeType"] = "dns-01" + } + temp = append(temp, &dWorkflowNode{ Id: current.Id, - Type: "biz" + strings.Title(current.Type), + Type: "bizApply", Data: dWorkflowNodeData{ Name: current.Name, Config: current.Config, @@ -762,6 +816,16 @@ func init() { }, }) + case "upload", "monitor", "notify": + temp = append(temp, &dWorkflowNode{ + Id: current.Id, + Type: "biz" + strings.Title(current.Type), + Data: dWorkflowNodeData{ + Name: current.Name, + Config: current.Config, + }, + }) + case "execute_result_branch": if len(temp) == 0 { break diff --git a/ui/src/components/workflow/WorkflowRunDetail.tsx b/ui/src/components/workflow/WorkflowRunDetail.tsx index 06fcb5a2e..fff54849a 100644 --- a/ui/src/components/workflow/WorkflowRunDetail.tsx +++ b/ui/src/components/workflow/WorkflowRunDetail.tsx @@ -54,7 +54,7 @@ const WorkflowRunLogs = ({ runId, runStatus }: { runId: string; runStatus: strin }, { refreshDeps: [runId, runStatus], - pollingInterval: runStatus === WORKFLOW_RUN_STATUSES.PENDING || runStatus === WORKFLOW_RUN_STATUSES.RUNNING ? 3000 : 0, + pollingInterval: runStatus === WORKFLOW_RUN_STATUSES.PENDING || runStatus === WORKFLOW_RUN_STATUSES.PROCESSING ? 3000 : 0, pollingWhenHidden: false, throttleWait: 500, onSuccess: (res) => { diff --git a/ui/src/components/workflow/WorkflowStatus.tsx b/ui/src/components/workflow/WorkflowStatus.tsx index 1b4ffcba1..0e5259d37 100644 --- a/ui/src/components/workflow/WorkflowStatus.tsx +++ b/ui/src/components/workflow/WorkflowStatus.tsx @@ -24,7 +24,7 @@ const useColor = (value: WorkflorRunStatusType | string, defaultColor?: string | return themeToken.colorTextSecondary; } break; - case WORKFLOW_RUN_STATUSES.RUNNING: + case WORKFLOW_RUN_STATUSES.PROCESSING: if (defaultColor == null || !defaultColor) { return themeToken.colorInfo; } @@ -73,7 +73,7 @@ const WorkflowStatusIcon = ({ className, style, size = "1.25em", type = "outline {type === "filled" ? : } ); - case WORKFLOW_RUN_STATUSES.RUNNING: + case WORKFLOW_RUN_STATUSES.PROCESSING: return ( @@ -125,7 +125,7 @@ const WorkflowStatus = ({ className, style, children, showIcon = true, type, val switch (value) { case WORKFLOW_RUN_STATUSES.PENDING: - case WORKFLOW_RUN_STATUSES.RUNNING: + case WORKFLOW_RUN_STATUSES.PROCESSING: case WORKFLOW_RUN_STATUSES.SUCCEEDED: case WORKFLOW_RUN_STATUSES.FAILED: case WORKFLOW_RUN_STATUSES.CANCELED: diff --git a/ui/src/domain/workflowRun.ts b/ui/src/domain/workflowRun.ts index bd118e480..c899f427a 100644 --- a/ui/src/domain/workflowRun.ts +++ b/ui/src/domain/workflowRun.ts @@ -14,7 +14,7 @@ export interface WorkflowRunModel extends BaseModel { export const WORKFLOW_RUN_STATUSES = Object.freeze({ PENDING: "pending", - RUNNING: "running", + PROCESSING: "processing", SUCCEEDED: "succeeded", FAILED: "failed", CANCELED: "canceled", diff --git a/ui/src/i18n/locales/en/nls.workflow.runs.json b/ui/src/i18n/locales/en/nls.workflow.runs.json index a06b7d2a3..f2da6d123 100644 --- a/ui/src/i18n/locales/en/nls.workflow.runs.json +++ b/ui/src/i18n/locales/en/nls.workflow.runs.json @@ -16,7 +16,7 @@ "workflow_run.props.status": "Status", "workflow_run.props.status.pending": "Pending", - "workflow_run.props.status.running": "Running", + "workflow_run.props.status.processing": "Processing", "workflow_run.props.status.succeeded": "Succeeded", "workflow_run.props.status.failed": "Failed", "workflow_run.props.status.canceled": "Canceled", diff --git a/ui/src/i18n/locales/zh/nls.workflow.runs.json b/ui/src/i18n/locales/zh/nls.workflow.runs.json index 08fd62651..f38749b43 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.runs.json +++ b/ui/src/i18n/locales/zh/nls.workflow.runs.json @@ -16,7 +16,7 @@ "workflow_run.props.status": "状态", "workflow_run.props.status.pending": "等待运行", - "workflow_run.props.status.running": "运行中", + "workflow_run.props.status.processing": "运行中", "workflow_run.props.status.succeeded": "已成功", "workflow_run.props.status.failed": "已失败", "workflow_run.props.status.canceled": "已取消", diff --git a/ui/src/pages/workflows/WorkflowDetail.tsx b/ui/src/pages/workflows/WorkflowDetail.tsx index 84cc47160..bca40db73 100644 --- a/ui/src/pages/workflows/WorkflowDetail.tsx +++ b/ui/src/pages/workflows/WorkflowDetail.tsx @@ -57,8 +57,8 @@ const WorkflowDetail = () => { const runButtonDisabled = useMemo(() => !workflow.hasContent, [workflow]); const [runButtonLoading, setRunButtonLoading] = useState(false); useEffect(() => { - const pending = workflow.lastRunStatus === WORKFLOW_RUN_STATUSES.PENDING || workflow.lastRunStatus === WORKFLOW_RUN_STATUSES.RUNNING; - setRunButtonLoading(pending); + const running = workflow.lastRunStatus === WORKFLOW_RUN_STATUSES.PENDING || workflow.lastRunStatus === WORKFLOW_RUN_STATUSES.PROCESSING; + setRunButtonLoading(running); }, [workflow.lastRunStatus]); const handleRunClick = () => { diff --git a/ui/src/pages/workflows/WorkflowDetailDesign.tsx b/ui/src/pages/workflows/WorkflowDetailDesign.tsx index 27f1a3d41..5a9c92e49 100644 --- a/ui/src/pages/workflows/WorkflowDetailDesign.tsx +++ b/ui/src/pages/workflows/WorkflowDetailDesign.tsx @@ -27,8 +27,8 @@ const WorkflowDetailDesign = () => { ); const workflowPublishDisabled = useMemo(() => workflowRunDisabled || !workflow.hasDraft, [workflowRunDisabled, workflow.hasDraft]); useEffect(() => { - const disabled = workflow.lastRunStatus === WORKFLOW_RUN_STATUSES.PENDING || workflow.lastRunStatus === WORKFLOW_RUN_STATUSES.RUNNING; - setWorkflowRunDisabled(disabled); + const running = workflow.lastRunStatus === WORKFLOW_RUN_STATUSES.PENDING || workflow.lastRunStatus === WORKFLOW_RUN_STATUSES.PROCESSING; + setWorkflowRunDisabled(running); }, [workflow.lastRunStatus]); const designerRef = useRef(null); diff --git a/ui/src/pages/workflows/WorkflowDetailRuns.tsx b/ui/src/pages/workflows/WorkflowDetailRuns.tsx index 730ead466..99521884a 100644 --- a/ui/src/pages/workflows/WorkflowDetailRuns.tsx +++ b/ui/src/pages/workflows/WorkflowDetailRuns.tsx @@ -99,7 +99,7 @@ const WorkflowDetailRuns = () => { fixed: "right", width: 64, render: (_, record) => { - const cancelDisabled = !([WORKFLOW_RUN_STATUSES.PENDING, WORKFLOW_RUN_STATUSES.RUNNING] as string[]).includes(record.status); + const cancelDisabled = !([WORKFLOW_RUN_STATUSES.PENDING, WORKFLOW_RUN_STATUSES.PROCESSING] as string[]).includes(record.status); const deleteDisabled = !cancelDisabled; return ( @@ -224,7 +224,7 @@ const WorkflowDetailRuns = () => { ); useEffect(() => { - const items = tableData.filter((e) => e.status === WORKFLOW_RUN_STATUSES.PENDING || e.status === WORKFLOW_RUN_STATUSES.RUNNING); + const items = tableData.filter((e) => e.status === WORKFLOW_RUN_STATUSES.PENDING || e.status === WORKFLOW_RUN_STATUSES.PROCESSING); for (const item of items) { subscribeWorkflowRun(item.id, (cb) => { setTableData((prev) => { @@ -239,7 +239,7 @@ const WorkflowDetailRuns = () => { detailDrawerProps.data = { ...detailDrawerProps.data, ...cb.record }; } - if (cb.record.status !== WORKFLOW_RUN_STATUSES.PENDING && cb.record.status !== WORKFLOW_RUN_STATUSES.RUNNING) { + if (cb.record.status !== WORKFLOW_RUN_STATUSES.PENDING && cb.record.status !== WORKFLOW_RUN_STATUSES.PROCESSING) { unsubscribeWorkflowRun(item.id); } }); @@ -325,7 +325,7 @@ const WorkflowDetailRuns = () => { okButtonProps: { danger: true }, onOk: async () => { // 未结束的记录不允许删除 - records = records.filter((record) => !([WORKFLOW_RUN_STATUSES.PENDING, WORKFLOW_RUN_STATUSES.RUNNING] as string[]).includes(record.status)); + records = records.filter((record) => !([WORKFLOW_RUN_STATUSES.PENDING, WORKFLOW_RUN_STATUSES.PROCESSING] as string[]).includes(record.status)); try { const resp = await removeWorkflowRun(records); if (resp) { From 50136dd339cc10f2d1c6534bb7dee565734b94d8 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Mon, 18 Aug 2025 15:48:38 +0800 Subject: [PATCH 09/20] fix(ui): node validation error --- .../forms/BranchBlockNodeConfigForm.tsx | 68 ++++++++++++++++--- ...nchBlockNodeConfigFormExpressionEditor.tsx | 13 +++- .../designer/nodes/BizDeployNodeRegistry.tsx | 5 +- .../workflow/designer/nodes/ConditionNode.tsx | 33 +++++++++ 4 files changed, 107 insertions(+), 12 deletions(-) diff --git a/ui/src/components/workflow/designer/forms/BranchBlockNodeConfigForm.tsx b/ui/src/components/workflow/designer/forms/BranchBlockNodeConfigForm.tsx index 3ac7a2343..a3929f5ed 100644 --- a/ui/src/components/workflow/designer/forms/BranchBlockNodeConfigForm.tsx +++ b/ui/src/components/workflow/designer/forms/BranchBlockNodeConfigForm.tsx @@ -5,7 +5,16 @@ import { type AnchorProps, Form, type FormInstance } from "antd"; import { createSchemaFieldRule } from "antd-zod"; import { z } from "zod"; -import { type WorkflowNodeConfigForBranchBlock, defaultNodeConfigForBranchBlock } from "@/domain/workflow"; +import { + type Expr, + type ExprComparisonOperator, + type ExprLogicalOperator, + ExprType, + type ExprValueType, + type WorkflowNodeConfigForBranchBlock, + defaultNodeConfigForBranchBlock, +} from "@/domain/workflow"; + import { useAntdForm } from "@/hooks"; import { NodeFormContextProvider } from "./_context"; @@ -34,11 +43,13 @@ const BranchBlockNodeConfigForm = ({ node, ...props }: BranchBlockNodeConfigForm try { await exprEditorRef.current!.validate(); } catch { - ctx.addIssue({ - code: "custom", - message: t("workflow_node.branch_block.form.expression.errmsg.invalid"), - path: ["expression"], - }); + if (!ctx.issues.some((issue) => issue.path?.[0] === "expression")) { + ctx.addIssue({ + code: "custom", + message: t("workflow_node.branch_block.form.expression.errmsg.invalid"), + path: ["expression"], + }); + } } } }); @@ -79,10 +90,51 @@ const getInitialValues = (): Nullish>> => { }; const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { - const { t: _ } = i18n; + const { t } = i18n; + + const exprSchema: z.ZodType = z.lazy(() => + z.discriminatedUnion("type", [ + z.object({ + type: z.literal(ExprType.Constant), + value: z.string(), + valueType: z.string(), + }), + + z.object({ + type: z.literal(ExprType.Variant), + selector: z.object({ + id: z.string(), + name: z.string(), + type: z.string(), + }), + }), + + z.object({ + type: z.literal(ExprType.Comparison), + operator: z.string(), + left: exprSchema, + right: exprSchema, + }), + + z.object({ + type: z.literal(ExprType.Logical), + operator: z.string(), + left: exprSchema, + right: exprSchema, + }), + + z.object({ + type: z.literal(ExprType.Not), + expr: exprSchema, + }), + ]) + ); return z.object({ - expression: z.any().nullish(), + expression: z + .any() + .nullish() + .refine((v) => v == null || exprSchema.safeParse(v).success, t("workflow_node.branch_block.form.expression.errmsg.invalid")), }); }; diff --git a/ui/src/components/workflow/designer/forms/BranchBlockNodeConfigFormExpressionEditor.tsx b/ui/src/components/workflow/designer/forms/BranchBlockNodeConfigFormExpressionEditor.tsx index 53a7474bf..4240bd03d 100644 --- a/ui/src/components/workflow/designer/forms/BranchBlockNodeConfigFormExpressionEditor.tsx +++ b/ui/src/components/workflow/designer/forms/BranchBlockNodeConfigFormExpressionEditor.tsx @@ -6,8 +6,15 @@ 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 { + type Expr, + type ExprComparisonOperator, + type ExprLogicalOperator, + ExprType, + type ExprValue, + type ExprValueSelector, + type ExprValueType, +} from "@/domain/workflow"; import { useAntdFormName } from "@/hooks"; import { useNodeFormContext } from "./_context"; @@ -146,7 +153,7 @@ const BranchBlockNodeConfigFormExpressionEditor = forwardRef(); - const formName = useAntdFormName({ form: formInst, name: "workflowNodeConditionConfigFormExpressionEditorForm" }); + const formName = useAntdFormName({ form: formInst, name: "workflowNodeBranchBlockConfigFormExpressionEditorForm" }); const [formModel, setFormModel] = useState(getInitialValues()); useEffect(() => { diff --git a/ui/src/components/workflow/designer/nodes/BizDeployNodeRegistry.tsx b/ui/src/components/workflow/designer/nodes/BizDeployNodeRegistry.tsx index a731a7b72..cd36aad77 100644 --- a/ui/src/components/workflow/designer/nodes/BizDeployNodeRegistry.tsx +++ b/ui/src/components/workflow/designer/nodes/BizDeployNodeRegistry.tsx @@ -39,7 +39,10 @@ export const BizDeployNodeRegistry: NodeRegistry = { } }, ["config.certificateOutputNodeId"]: ({ value, context: { node } }) => { - if (!getAllPreviousNodes(node).includes(value)) { + if (value == null) return; + + const prevNodeIds = getAllPreviousNodes(node).map((e) => e.id); + if (!prevNodeIds.includes(value)) { return { message: "Invalid input", level: FeedbackLevel.Error, diff --git a/ui/src/components/workflow/designer/nodes/ConditionNode.tsx b/ui/src/components/workflow/designer/nodes/ConditionNode.tsx index c8920a02d..ebb12e44b 100644 --- a/ui/src/components/workflow/designer/nodes/ConditionNode.tsx +++ b/ui/src/components/workflow/designer/nodes/ConditionNode.tsx @@ -5,6 +5,7 @@ import { Typography } from "antd"; import { type Expr, ExprType, newNode } from "@/domain/workflow"; +import { getAllPreviousNodes } from "../_util"; import { BaseNode, BranchNode } from "./_shared"; import { NodeKindType, type NodeRegistry, NodeType } from "./typings"; import BranchBlockNodeConfigForm from "../forms/BranchBlockNodeConfigForm"; @@ -72,6 +73,38 @@ export const BranchBlockNodeRegistry: NodeRegistry = { }; } }, + ["config.expression"]: ({ value, context: { node } }) => { + if (value == null) return; + + const prevNodeIds = getAllPreviousNodes(node).map((e) => e.id); + const deepValidate = (expr: Expr) => { + if ("selector" in expr) { + if (!prevNodeIds.includes(expr.selector.id)) { + return false; + } + } + + if ("left" in expr) { + if (!deepValidate(expr.left)) { + return false; + } + } + + if ("right" in expr) { + if (!deepValidate(expr.right)) { + return false; + } + } + + return true; + }; + if (!deepValidate(value)) { + return { + message: "Invalid input", + level: FeedbackLevel.Error, + }; + } + }, }, render: () => { From 1098dcce182d2283db42d2ec7761da71df2556c7 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Wed, 20 Aug 2025 15:54:19 +0800 Subject: [PATCH 10/20] feat: workflow engine adapt to new designer --- internal/applicant/applicant.go | 17 +- internal/deployer/deployer.go | 8 +- internal/domain/certificate.go | 2 +- internal/domain/workflow.go | 239 ++++++++-------- internal/domain/workflow_output.go | 18 +- internal/domain/workflow_run.go | 37 ++- internal/notify/notifier.go | 8 +- internal/repository/access.go | 4 +- internal/repository/acme_account.go | 4 +- internal/repository/certificate.go | 7 +- internal/repository/workflow.go | 10 +- internal/repository/workflow_log.go | 4 +- internal/repository/workflow_output.go | 15 +- internal/repository/workflow_run.go | 40 ++- internal/workflow/dispatcher/dispatcher.go | 33 ++- internal/workflow/dispatcher/invoker.go | 231 ++++++++------- internal/workflow/dispatcher/singleton.go | 8 +- internal/workflow/engine/context.go | 185 ++++++++++++ internal/workflow/engine/engine.go | 269 ++++++++++++++++++ internal/workflow/engine/errors.go | 7 + internal/workflow/engine/executor.go | 120 ++++++++ internal/workflow/engine/executor_bizapply.go | 207 ++++++++++++++ .../workflow/engine/executor_bizdeploy.go | 138 +++++++++ .../executor_bizmonitor.go} | 82 +++--- .../workflow/engine/executor_biznotify.go | 74 +++++ .../workflow/engine/executor_bizupload.go | 141 +++++++++ .../workflow/engine/executor_condition.go | 116 ++++++++ internal/workflow/engine/executor_end.go | 22 ++ internal/workflow/engine/executor_start.go | 21 ++ internal/workflow/engine/executor_trycatch.go | 129 +++++++++ internal/workflow/engine/logger.go | 9 + internal/workflow/engine/models.go | 34 +++ internal/workflow/engine/services.go | 27 ++ internal/workflow/event.go | 11 +- .../workflow/node-processor/apply_node.go | 174 ----------- .../workflow/node-processor/condition_node.go | 52 ---- internal/workflow/node-processor/const.go | 7 - internal/workflow/node-processor/context.go | 103 ------- .../workflow/node-processor/deploy_node.go | 129 --------- .../node-processor/execute_failure_node.go | 27 -- .../node-processor/execute_success_node.go | 27 -- .../node-processor/monitor_node_test.go | 28 -- .../workflow/node-processor/notify_node.go | 80 ------ internal/workflow/node-processor/processor.go | 104 ------- .../workflow/node-processor/start_node.go | 28 -- .../workflow/node-processor/upload_node.go | 107 ------- internal/workflow/service.go | 29 +- migrations/1755187200_v0.4.0_migrate.go | 18 +- pkg/logging/handler.go | 2 +- pkg/utils/cert/converter.go | 4 +- .../designer/forms/StartNodeConfigForm.tsx | 2 +- .../i18n/locales/en/nls.workflow.nodes.json | 4 +- .../i18n/locales/zh/nls.workflow.nodes.json | 4 +- 53 files changed, 1980 insertions(+), 1226 deletions(-) create mode 100644 internal/workflow/engine/context.go create mode 100644 internal/workflow/engine/engine.go create mode 100644 internal/workflow/engine/errors.go create mode 100644 internal/workflow/engine/executor.go create mode 100644 internal/workflow/engine/executor_bizapply.go create mode 100644 internal/workflow/engine/executor_bizdeploy.go rename internal/workflow/{node-processor/monitor_node.go => engine/executor_bizmonitor.go} (50%) create mode 100644 internal/workflow/engine/executor_biznotify.go create mode 100644 internal/workflow/engine/executor_bizupload.go create mode 100644 internal/workflow/engine/executor_condition.go create mode 100644 internal/workflow/engine/executor_end.go create mode 100644 internal/workflow/engine/executor_start.go create mode 100644 internal/workflow/engine/executor_trycatch.go create mode 100644 internal/workflow/engine/logger.go create mode 100644 internal/workflow/engine/models.go create mode 100644 internal/workflow/engine/services.go delete mode 100644 internal/workflow/node-processor/apply_node.go delete mode 100644 internal/workflow/node-processor/condition_node.go delete mode 100644 internal/workflow/node-processor/const.go delete mode 100644 internal/workflow/node-processor/context.go delete mode 100644 internal/workflow/node-processor/deploy_node.go delete mode 100644 internal/workflow/node-processor/execute_failure_node.go delete mode 100644 internal/workflow/node-processor/execute_success_node.go delete mode 100644 internal/workflow/node-processor/monitor_node_test.go delete mode 100644 internal/workflow/node-processor/notify_node.go delete mode 100644 internal/workflow/node-processor/processor.go delete mode 100644 internal/workflow/node-processor/start_node.go delete mode 100644 internal/workflow/node-processor/upload_node.go diff --git a/internal/applicant/applicant.go b/internal/applicant/applicant.go index fed38fc9b..49a56d0b6 100644 --- a/internal/applicant/applicant.go +++ b/internal/applicant/applicant.go @@ -41,19 +41,20 @@ type Applicant interface { } type ApplicantWithWorkflowNodeConfig struct { - Node *domain.WorkflowNode - Logger *slog.Logger + WorkflowId string + Node *domain.WorkflowNode + Logger *slog.Logger } func NewWithWorkflowNode(config ApplicantWithWorkflowNodeConfig) (Applicant, error) { if config.Node == nil { - return nil, fmt.Errorf("node is nil") + return nil, fmt.Errorf("the node is nil") } - if config.Node.Type != domain.WorkflowNodeTypeApply { - return nil, fmt.Errorf("node type is not '%s'", string(domain.WorkflowNodeTypeApply)) + if config.Node.Type != domain.WorkflowNodeTypeBizApply { + return nil, fmt.Errorf("the node type is '%s', expected '%s'", string(config.Node.Type), string(domain.WorkflowNodeTypeBizApply)) } - nodeCfg := config.Node.GetConfigForApply() + nodeCfg := config.Node.GetConfigForBizApply() options := &applicantProviderOptions{ Domains: lo.Filter(strings.Split(nodeCfg.Domains, ";"), func(s string, _ int) bool { return s != "" }), ContactEmail: nodeCfg.ContactEmail, @@ -109,8 +110,8 @@ func NewWithWorkflowNode(config ApplicantWithWorkflowNodeConfig) (Applicant, err options.CAProviderAccessConfig = sslProviderConfig.Config[options.CAProvider] } - certRepo := repository.NewCertificateRepository() - lastCertificate, _ := certRepo.GetByWorkflowNodeId(context.Background(), config.Node.Id) + certificateRepo := repository.NewCertificateRepository() + lastCertificate, _ := certificateRepo.GetByWorkflowIdAndNodeId(context.Background(), config.WorkflowId, config.Node.Id) if lastCertificate != nil && !lastCertificate.ACMERenewed { newCertSan := slices.Clone(options.Domains) oldCertSan := strings.Split(lastCertificate.SubjectAltNames, ";") diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index be98eee2f..fc1463cbc 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -23,13 +23,13 @@ type DeployerWithWorkflowNodeConfig struct { func NewWithWorkflowNode(config DeployerWithWorkflowNodeConfig) (Deployer, error) { if config.Node == nil { - return nil, fmt.Errorf("node is nil") + return nil, fmt.Errorf("the node is nil") } - if config.Node.Type != domain.WorkflowNodeTypeDeploy { - return nil, fmt.Errorf("node type is not '%s'", string(domain.WorkflowNodeTypeDeploy)) + if config.Node.Type != domain.WorkflowNodeTypeBizDeploy { + return nil, fmt.Errorf("the node type is '%s', expected '%s'", string(config.Node.Type), string(domain.WorkflowNodeTypeBizDeploy)) } - nodeCfg := config.Node.GetConfigForDeploy() + nodeCfg := config.Node.GetConfigForBizDeploy() options := &deployerProviderOptions{ Provider: domain.DeploymentProviderType(nodeCfg.Provider), ProviderAccessConfig: make(map[string]any), diff --git a/internal/domain/certificate.go b/internal/domain/certificate.go index ffa4b24a8..e069ff7e1 100644 --- a/internal/domain/certificate.go +++ b/internal/domain/certificate.go @@ -111,7 +111,7 @@ func (c *Certificate) PopulateFromPEM(certPEM, privkeyPEM string) *Certificate { certX509, _ := xcert.ParseCertificateFromPEM(certPEM) if certX509 != nil { - c.PopulateFromX509(certX509) + return c.PopulateFromX509(certX509) } return c diff --git a/internal/domain/workflow.go b/internal/domain/workflow.go index 743122461..008ba59c7 100644 --- a/internal/domain/workflow.go +++ b/internal/domain/workflow.go @@ -2,6 +2,7 @@ package domain import ( "encoding/json" + "fmt" "time" "github.com/certimate-go/certimate/internal/domain/expr" @@ -17,8 +18,8 @@ type Workflow struct { Trigger WorkflowTriggerType `json:"trigger" db:"trigger"` TriggerCron string `json:"triggerCron" db:"triggerCron"` Enabled bool `json:"enabled" db:"enabled"` - GraphDraft *WorkflowNode `json:"graphDraft" db:"graphDraft"` - GraphContent *WorkflowNode `json:"graphContent" db:"graphContent"` + GraphDraft *WorkflowGraph `json:"graphDraft" db:"graphDraft"` + GraphContent *WorkflowGraph `json:"graphContent" db:"graphContent"` HasDraft bool `json:"hasDraft" db:"hasDraft"` HasContent bool `json:"hasContent" db:"hasContent"` LastRunId string `json:"lastRunId" db:"lastRunRef"` @@ -26,22 +27,41 @@ type Workflow struct { LastRunTime time.Time `json:"lastRunTime" db:"lastRunTime"` } -type WorkflowNodeType string +type WorkflowGraph struct { + Nodes []*WorkflowNode `json:"nodes"` +} -const ( - WorkflowNodeTypeStart = WorkflowNodeType("start") - WorkflowNodeTypeEnd = WorkflowNodeType("end") - WorkflowNodeTypeApply = WorkflowNodeType("apply") - WorkflowNodeTypeUpload = WorkflowNodeType("upload") - WorkflowNodeTypeMonitor = WorkflowNodeType("monitor") - WorkflowNodeTypeDeploy = WorkflowNodeType("deploy") - WorkflowNodeTypeNotify = WorkflowNodeType("notify") - WorkflowNodeTypeBranch = WorkflowNodeType("branch") - WorkflowNodeTypeCondition = WorkflowNodeType("condition") - WorkflowNodeTypeExecuteResultBranch = WorkflowNodeType("execute_result_branch") - WorkflowNodeTypeExecuteSuccess = WorkflowNodeType("execute_success") - WorkflowNodeTypeExecuteFailure = WorkflowNodeType("execute_failure") -) +func (g *WorkflowGraph) GetNodeById(nodeId string) (*WorkflowNode, bool) { + return g.getNodeInBlocksById(g.Nodes, nodeId) +} + +func (g *WorkflowGraph) getNodeInBlocksById(blocks []*WorkflowNode, nodeId string) (*WorkflowNode, bool) { + for _, node := range blocks { + if node.Id == nodeId { + return node, true + } + + if len(node.Blocks) > 0 { + if found, ok := g.getNodeInBlocksById(node.Blocks, nodeId); ok { + return found, true + } + } + } + + return nil, false +} + +func (g *WorkflowGraph) Verify() error { + if len(g.Nodes) < 2 { + return fmt.Errorf("invalid nodes length of graph") + } else if g.Nodes[0].Type != WorkflowNodeTypeStart { + return fmt.Errorf("the first node is not a start node") + } else if g.Nodes[len(g.Nodes)-1].Type != WorkflowNodeTypeEnd { + return fmt.Errorf("the last node is not an end node") + } + + return nil +} type WorkflowTriggerType string @@ -50,33 +70,46 @@ const ( WorkflowTriggerTypeManual = WorkflowTriggerType("manual") ) -// Deprecated type WorkflowNode struct { - Id string `json:"id"` - Type WorkflowNodeType `json:"type"` - Name string `json:"name"` - - Config map[string]any `json:"config"` - Inputs []WorkflowNodeIO `json:"inputs,omitempty"` - Outputs []WorkflowNodeIO `json:"outputs,omitempty"` - - Next *WorkflowNode `json:"next,omitempty"` - Branches []WorkflowNode `json:"branches,omitempty"` - - Validated bool `json:"validated"` + Id string `json:"id"` // 节点 ID 只在该工作流中唯一,在全局中不保证唯一性 + Type WorkflowNodeType `json:"type"` + Data WorkflowNodeData `json:"data"` + Blocks []*WorkflowNode `json:"blocks,omitempty"` } -type WorkflowNodeConfigForApply struct { +type WorkflowNodeType string + +const ( + WorkflowNodeTypeStart = WorkflowNodeType("start") + WorkflowNodeTypeEnd = WorkflowNodeType("end") + WorkflowNodeTypeCondition = WorkflowNodeType("condition") + WorkflowNodeTypeBranchBlock = WorkflowNodeType("branchBlock") + WorkflowNodeTypeTryCatch = WorkflowNodeType("tryCatch") + WorkflowNodeTypeTryBlock = WorkflowNodeType("tryBlock") + WorkflowNodeTypeCatchBlock = WorkflowNodeType("catchBlock") + WorkflowNodeTypeBizApply = WorkflowNodeType("bizApply") + WorkflowNodeTypeBizUpload = WorkflowNodeType("bizUpload") + WorkflowNodeTypeBizMonitor = WorkflowNodeType("bizMonitor") + WorkflowNodeTypeBizDeploy = WorkflowNodeType("bizDeploy") + WorkflowNodeTypeBizNotify = WorkflowNodeType("bizNotify") +) + +type WorkflowNodeData struct { + Name string `json:"name"` + Config map[string]any `json:"config"` +} + +type WorkflowNodeConfigForBizApply struct { Domains string `json:"domains"` // 域名列表,以半角分号分隔 ContactEmail string `json:"contactEmail"` // 联系邮箱 - ChallengeType string `json:"challengeType"` // TODO: 验证方式。目前仅支持 dns-01 + ChallengeType string `json:"challengeType"` // 验证方式。目前仅支持 dns-01 Provider string `json:"provider"` // DNS 提供商 ProviderAccessId string `json:"providerAccessId"` // DNS 提供商授权记录 ID ProviderConfig map[string]any `json:"providerConfig,omitempty"` // DNS 提供商额外配置 + KeyAlgorithm string `json:"keyAlgorithm,omitempty"` // 证书算法 CAProvider string `json:"caProvider,omitempty"` // CA 提供商(零值时使用全局配置) CAProviderAccessId string `json:"caProviderAccessId,omitempty"` // CA 提供商授权记录 ID CAProviderConfig map[string]any `json:"caProviderConfig,omitempty"` // CA 提供商额外配置 - KeyAlgorithm string `json:"keyAlgorithm,omitempty"` // 证书算法 ACMEProfile string `json:"acmeProfile,omitempty"` // ACME Profiles Extension Nameservers string `json:"nameservers,omitempty"` // DNS 服务器列表,以半角分号分隔 DnsPropagationWait int32 `json:"dnsPropagationWait,omitempty"` // DNS 传播等待时间,等同于 lego 的 `--dns-propagation-wait` 参数 @@ -84,31 +117,31 @@ type WorkflowNodeConfigForApply struct { DnsTTL int32 `json:"dnsTTL,omitempty"` // DNS 解析记录 TTL(零值时使用提供商的默认值) DisableFollowCNAME bool `json:"disableFollowCNAME,omitempty"` // 是否关闭 CNAME 跟随 DisableARI bool `json:"disableARI,omitempty"` // 是否关闭 ARI - SkipBeforeExpiryDays int32 `json:"skipBeforeExpiryDays,omitempty"` // 证书到期前多少天前跳过续期(零值时默认值 30) + SkipBeforeExpiryDays int32 `json:"skipBeforeExpiryDays,omitempty"` // 证书到期前多少天前跳过续期 } -type WorkflowNodeConfigForUpload struct { +type WorkflowNodeConfigForBizUpload struct { Certificate string `json:"certificate"` // 证书 PEM 内容 PrivateKey string `json:"privateKey"` // 私钥 PEM 内容 Domains string `json:"domains,omitempty"` } -type WorkflowNodeConfigForMonitor struct { +type WorkflowNodeConfigForBizMonitor 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 { - Certificate string `json:"certificate"` // 前序节点输出的证书,形如“${NodeId}#certificate” - Provider string `json:"provider"` // 主机提供商 - ProviderAccessId string `json:"providerAccessId,omitempty"` // 主机提供商授权记录 ID - ProviderConfig map[string]any `json:"providerConfig,omitempty"` // 主机提供商额外配置 - SkipOnLastSucceeded bool `json:"skipOnLastSucceeded"` // 上次部署成功时是否跳过 +type WorkflowNodeConfigForBizDeploy struct { + CertificateOutputNodeId string `json:"certificateOutputNodeId"` // 前序证书输出节点 ID + Provider string `json:"provider"` // 主机提供商 + ProviderAccessId string `json:"providerAccessId,omitempty"` // 主机提供商授权记录 ID + ProviderConfig map[string]any `json:"providerConfig,omitempty"` // 主机提供商额外配置 + SkipOnLastSucceeded bool `json:"skipOnLastSucceeded"` // 上次部署成功时是否跳过 } -type WorkflowNodeConfigForNotify struct { +type WorkflowNodeConfigForBizNotify struct { Provider string `json:"provider"` // 通知提供商 ProviderAccessId string `json:"providerAccessId"` // 通知提供商授权记录 ID ProviderConfig map[string]any `json:"providerConfig,omitempty"` // 通知提供商额外配置 @@ -117,101 +150,85 @@ type WorkflowNodeConfigForNotify struct { SkipOnAllPrevSkipped bool `json:"skipOnAllPrevSkipped"` // 前序节点均已跳过时是否跳过 } -type WorkflowNodeConfigForCondition struct { +type WorkflowNodeConfigForBranchBlock struct { Expression expr.Expr `json:"expression"` // 条件表达式 } -func (n *WorkflowNode) GetConfigForApply() WorkflowNodeConfigForApply { - return WorkflowNodeConfigForApply{ - Domains: xmaps.GetString(n.Config, "domains"), - ContactEmail: xmaps.GetString(n.Config, "contactEmail"), - ChallengeType: xmaps.GetString(n.Config, "challengeType"), - Provider: xmaps.GetString(n.Config, "provider"), - ProviderAccessId: xmaps.GetString(n.Config, "providerAccessId"), - ProviderConfig: xmaps.GetKVMapAny(n.Config, "providerConfig"), - CAProvider: xmaps.GetString(n.Config, "caProvider"), - CAProviderAccessId: xmaps.GetString(n.Config, "caProviderAccessId"), - CAProviderConfig: xmaps.GetKVMapAny(n.Config, "caProviderConfig"), - KeyAlgorithm: xmaps.GetOrDefaultString(n.Config, "keyAlgorithm", string(CertificateKeyAlgorithmTypeRSA2048)), - ACMEProfile: xmaps.GetString(n.Config, "acmeProfile"), - Nameservers: xmaps.GetString(n.Config, "nameservers"), - DnsPropagationWait: xmaps.GetInt32(n.Config, "dnsPropagationWait"), - DnsPropagationTimeout: xmaps.GetInt32(n.Config, "dnsPropagationTimeout"), - DnsTTL: xmaps.GetInt32(n.Config, "dnsTTL"), - DisableFollowCNAME: xmaps.GetBool(n.Config, "disableFollowCNAME"), - DisableARI: xmaps.GetBool(n.Config, "disableARI"), - SkipBeforeExpiryDays: xmaps.GetOrDefaultInt32(n.Config, "skipBeforeExpiryDays", 30), +func (n *WorkflowNode) GetConfigForBizApply() WorkflowNodeConfigForBizApply { + return WorkflowNodeConfigForBizApply{ + Domains: xmaps.GetString(n.Data.Config, "domains"), + ContactEmail: xmaps.GetString(n.Data.Config, "contactEmail"), + ChallengeType: xmaps.GetString(n.Data.Config, "challengeType"), + Provider: xmaps.GetString(n.Data.Config, "provider"), + ProviderAccessId: xmaps.GetString(n.Data.Config, "providerAccessId"), + ProviderConfig: xmaps.GetKVMapAny(n.Data.Config, "providerConfig"), + KeyAlgorithm: xmaps.GetOrDefaultString(n.Data.Config, "keyAlgorithm", string(CertificateKeyAlgorithmTypeRSA2048)), + CAProvider: xmaps.GetString(n.Data.Config, "caProvider"), + CAProviderAccessId: xmaps.GetString(n.Data.Config, "caProviderAccessId"), + CAProviderConfig: xmaps.GetKVMapAny(n.Data.Config, "caProviderConfig"), + ACMEProfile: xmaps.GetString(n.Data.Config, "acmeProfile"), + Nameservers: xmaps.GetString(n.Data.Config, "nameservers"), + DnsPropagationWait: xmaps.GetInt32(n.Data.Config, "dnsPropagationWait"), + DnsPropagationTimeout: xmaps.GetInt32(n.Data.Config, "dnsPropagationTimeout"), + DnsTTL: xmaps.GetInt32(n.Data.Config, "dnsTTL"), + DisableFollowCNAME: xmaps.GetBool(n.Data.Config, "disableFollowCNAME"), + DisableARI: xmaps.GetBool(n.Data.Config, "disableARI"), + SkipBeforeExpiryDays: xmaps.GetInt32(n.Data.Config, "skipBeforeExpiryDays"), } } -func (n *WorkflowNode) GetConfigForUpload() WorkflowNodeConfigForUpload { - return WorkflowNodeConfigForUpload{ - Certificate: xmaps.GetString(n.Config, "certificate"), - PrivateKey: xmaps.GetString(n.Config, "privateKey"), - Domains: xmaps.GetString(n.Config, "domains"), +func (n *WorkflowNode) GetConfigForBizUpload() WorkflowNodeConfigForBizUpload { + return WorkflowNodeConfigForBizUpload{ + Certificate: xmaps.GetString(n.Data.Config, "certificate"), + PrivateKey: xmaps.GetString(n.Data.Config, "privateKey"), + Domains: xmaps.GetString(n.Data.Config, "domains"), } } -func (n *WorkflowNode) GetConfigForMonitor() WorkflowNodeConfigForMonitor { - host := xmaps.GetString(n.Config, "host") - return WorkflowNodeConfigForMonitor{ +func (n *WorkflowNode) GetConfigForBizMonitor() WorkflowNodeConfigForBizMonitor { + host := xmaps.GetString(n.Data.Config, "host") + return WorkflowNodeConfigForBizMonitor{ Host: host, - Port: xmaps.GetOrDefaultInt32(n.Config, "port", 443), - Domain: xmaps.GetOrDefaultString(n.Config, "domain", host), - RequestPath: xmaps.GetString(n.Config, "path"), + Port: xmaps.GetOrDefaultInt32(n.Data.Config, "port", 443), + Domain: xmaps.GetOrDefaultString(n.Data.Config, "domain", host), + RequestPath: xmaps.GetString(n.Data.Config, "path"), } } -func (n *WorkflowNode) GetConfigForDeploy() WorkflowNodeConfigForDeploy { - return WorkflowNodeConfigForDeploy{ - Certificate: xmaps.GetString(n.Config, "certificate"), - Provider: xmaps.GetString(n.Config, "provider"), - ProviderAccessId: xmaps.GetString(n.Config, "providerAccessId"), - ProviderConfig: xmaps.GetKVMapAny(n.Config, "providerConfig"), - SkipOnLastSucceeded: xmaps.GetBool(n.Config, "skipOnLastSucceeded"), +func (n *WorkflowNode) GetConfigForBizDeploy() WorkflowNodeConfigForBizDeploy { + return WorkflowNodeConfigForBizDeploy{ + CertificateOutputNodeId: xmaps.GetString(n.Data.Config, "certificateOutputNodeId"), + Provider: xmaps.GetString(n.Data.Config, "provider"), + ProviderAccessId: xmaps.GetString(n.Data.Config, "providerAccessId"), + ProviderConfig: xmaps.GetKVMapAny(n.Data.Config, "providerConfig"), + SkipOnLastSucceeded: xmaps.GetBool(n.Data.Config, "skipOnLastSucceeded"), } } -func (n *WorkflowNode) GetConfigForNotify() WorkflowNodeConfigForNotify { - return WorkflowNodeConfigForNotify{ - Provider: xmaps.GetString(n.Config, "provider"), - ProviderAccessId: xmaps.GetString(n.Config, "providerAccessId"), - ProviderConfig: xmaps.GetKVMapAny(n.Config, "providerConfig"), - Subject: xmaps.GetString(n.Config, "subject"), - Message: xmaps.GetString(n.Config, "message"), - SkipOnAllPrevSkipped: xmaps.GetBool(n.Config, "skipOnAllPrevSkipped"), +func (n *WorkflowNode) GetConfigForBizNotify() WorkflowNodeConfigForBizNotify { + return WorkflowNodeConfigForBizNotify{ + Provider: xmaps.GetString(n.Data.Config, "provider"), + ProviderAccessId: xmaps.GetString(n.Data.Config, "providerAccessId"), + ProviderConfig: xmaps.GetKVMapAny(n.Data.Config, "providerConfig"), + Subject: xmaps.GetString(n.Data.Config, "subject"), + Message: xmaps.GetString(n.Data.Config, "message"), + SkipOnAllPrevSkipped: xmaps.GetBool(n.Data.Config, "skipOnAllPrevSkipped"), } } -func (n *WorkflowNode) GetConfigForCondition() WorkflowNodeConfigForCondition { - expression := n.Config["expression"] +func (n *WorkflowNode) GetConfigForBranchBlock() WorkflowNodeConfigForBranchBlock { + expression := n.Data.Config["expression"] if expression == nil { - return WorkflowNodeConfigForCondition{} + return WorkflowNodeConfigForBranchBlock{} } exprRaw, _ := json.Marshal(expression) expr, err := expr.UnmarshalExpr([]byte(exprRaw)) if err != nil { - return WorkflowNodeConfigForCondition{} + return WorkflowNodeConfigForBranchBlock{} } - return WorkflowNodeConfigForCondition{ + return WorkflowNodeConfigForBranchBlock{ Expression: expr, } } - -// Deprecated -type WorkflowNodeIO struct { - Label string `json:"label"` - Name string `json:"name"` - Type string `json:"type"` - Required bool `json:"required"` - Value any `json:"value"` - ValueSelector WorkflowNodeIOValueSelector `json:"valueSelector"` -} - -// Deprecated -type WorkflowNodeIOValueSelector = expr.ExprValueSelector - -// Deprecated -const WorkflowNodeIONameCertificate string = "certificate" diff --git a/internal/domain/workflow_output.go b/internal/domain/workflow_output.go index b8bb12121..2108f73b6 100644 --- a/internal/domain/workflow_output.go +++ b/internal/domain/workflow_output.go @@ -4,10 +4,16 @@ const CollectionNameWorkflowOutput = "workflow_output" type WorkflowOutput struct { Meta - WorkflowId string `json:"workflowId" db:"workflowRef"` - RunId string `json:"runId" db:"runRef"` - NodeId string `json:"nodeId" db:"nodeId"` - Node *WorkflowNode `json:"node" db:"node"` - Outputs []WorkflowNodeIO `json:"outputs" db:"outputs"` // Deprecated - Succeeded bool `json:"succeeded" db:"succeeded"` + WorkflowId string `json:"workflowId" db:"workflowRef"` + RunId string `json:"runId" db:"runRef"` + NodeId string `json:"nodeId" db:"nodeId"` + Outputs []*WorkflowOutputEntry `json:"outputs" db:"outputs"` + Succeeded bool `json:"succeeded" db:"succeeded"` +} + +type WorkflowOutputEntry struct { + Type string `json:"type"` + Name string `json:"name"` + Value any `json:"value"` + ValueType string `json:"valueType"` } diff --git a/internal/domain/workflow_run.go b/internal/domain/workflow_run.go index ad771e465..ef0e2e543 100644 --- a/internal/domain/workflow_run.go +++ b/internal/domain/workflow_run.go @@ -8,21 +8,34 @@ const CollectionNameWorkflowRun = "workflow_run" type WorkflowRun struct { Meta - WorkflowId string `json:"workflowId" db:"workflowRef"` - Status WorkflowRunStatusType `json:"status" db:"status"` - Trigger WorkflowTriggerType `json:"trigger" db:"trigger"` - StartedAt time.Time `json:"startedAt" db:"startedAt"` - EndedAt time.Time `json:"endedAt" db:"endedAt"` - Graph *WorkflowNode `json:"graph" db:"graph"` - Error string `json:"error" db:"error"` + WorkflowId string `json:"workflowId" db:"workflowRef"` + Status WorkflowRunStatusType `json:"status" db:"status"` + Trigger WorkflowTriggerType `json:"trigger" db:"trigger"` + StartedAt time.Time `json:"startedAt" db:"startedAt"` + EndedAt time.Time `json:"endedAt" db:"endedAt"` + Graph *WorkflowGraphWithResult `json:"graph" db:"graph"` + Error string `json:"error" db:"error"` } type WorkflowRunStatusType string const ( - WorkflowRunStatusTypePending WorkflowRunStatusType = "pending" - WorkflowRunStatusTypeRunning WorkflowRunStatusType = "running" - WorkflowRunStatusTypeSucceeded WorkflowRunStatusType = "succeeded" - WorkflowRunStatusTypeFailed WorkflowRunStatusType = "failed" - WorkflowRunStatusTypeCanceled WorkflowRunStatusType = "canceled" + WorkflowRunStatusTypePending WorkflowRunStatusType = "pending" + WorkflowRunStatusTypeProcessing WorkflowRunStatusType = "processing" + WorkflowRunStatusTypeSucceeded WorkflowRunStatusType = "succeeded" + WorkflowRunStatusTypeFailed WorkflowRunStatusType = "failed" + WorkflowRunStatusTypeCanceled WorkflowRunStatusType = "canceled" ) + +type WorkflowGraphWithResult struct { + WorkflowGraph + Results []*WorkflowGraphResultEntry `json:"results,omitempty"` +} + +type WorkflowGraphResultEntry struct { + NodeId string `json:"nodeId"` + StartedAt time.Time `json:"startedAt,omitempty"` + EndedAt time.Time `json:"endedAt,omitempty"` + Succeeded bool `json:"succeeded,omitempty"` + Skipped bool `json:"skipped,omitempty"` +} diff --git a/internal/notify/notifier.go b/internal/notify/notifier.go index 975b7ab26..533432d6f 100644 --- a/internal/notify/notifier.go +++ b/internal/notify/notifier.go @@ -23,13 +23,13 @@ type NotifierWithWorkflowNodeConfig struct { func NewWithWorkflowNode(config NotifierWithWorkflowNodeConfig) (Notifier, error) { if config.Node == nil { - return nil, fmt.Errorf("node is nil") + return nil, fmt.Errorf("the node is nil") } - if config.Node.Type != domain.WorkflowNodeTypeNotify { - return nil, fmt.Errorf("node type is not '%s'", string(domain.WorkflowNodeTypeNotify)) + if config.Node.Type != domain.WorkflowNodeTypeBizNotify { + return nil, fmt.Errorf("the node type is '%s', expected '%s'", string(config.Node.Type), string(domain.WorkflowNodeTypeBizNotify)) } - nodeCfg := config.Node.GetConfigForNotify() + nodeCfg := config.Node.GetConfigForBizNotify() options := ¬ifierProviderOptions{ Provider: domain.NotificationProviderType(nodeCfg.Provider), ProviderAccessConfig: make(map[string]any), diff --git a/internal/repository/access.go b/internal/repository/access.go index a0e7e9351..0ceeb067f 100644 --- a/internal/repository/access.go +++ b/internal/repository/access.go @@ -36,12 +36,12 @@ func (r *AccessRepository) GetById(ctx context.Context, id string) (*domain.Acce func (r *AccessRepository) castRecordToModel(record *core.Record) (*domain.Access, error) { if record == nil { - return nil, fmt.Errorf("record is nil") + return nil, fmt.Errorf("the record is nil") } config := make(map[string]any) if err := record.UnmarshalJSONField("config", &config); err != nil { - return nil, err + return nil, fmt.Errorf("field 'config' is malformed") } access := &domain.Access{ diff --git a/internal/repository/acme_account.go b/internal/repository/acme_account.go index 7b46e77c1..e8b45654b 100644 --- a/internal/repository/acme_account.go +++ b/internal/repository/acme_account.go @@ -86,12 +86,12 @@ func (r *AcmeAccountRepository) Save(ctx context.Context, acmeAccount *domain.Ac func (r *AcmeAccountRepository) castRecordToModel(record *core.Record) (*domain.AcmeAccount, error) { if record == nil { - return nil, fmt.Errorf("record is nil") + return nil, fmt.Errorf("the record is nil") } resource := ®istration.Resource{} if err := record.UnmarshalJSONField("resource", resource); err != nil { - return nil, err + return nil, fmt.Errorf("field 'resource' is malformed") } acmeAccount := &domain.AcmeAccount{ diff --git a/internal/repository/certificate.go b/internal/repository/certificate.go index 5c8abd33b..7447bf632 100644 --- a/internal/repository/certificate.go +++ b/internal/repository/certificate.go @@ -58,12 +58,13 @@ func (r *CertificateRepository) GetById(ctx context.Context, id string) (*domain return r.castRecordToModel(record) } -func (r *CertificateRepository) GetByWorkflowNodeId(ctx context.Context, workflowNodeId string) (*domain.Certificate, error) { +func (r *CertificateRepository) GetByWorkflowIdAndNodeId(ctx context.Context, workflowId string, workflowNodeId string) (*domain.Certificate, error) { records, err := app.GetApp().FindRecordsByFilter( domain.CollectionNameCertificate, - "workflowNodeId={:workflowNodeId} && deleted=null", + "workflowRef={:workflowId} && workflowNodeId={:workflowNodeId} && deleted=null", "-created", 1, 0, + dbx.Params{"workflowId": workflowId}, dbx.Params{"workflowNodeId": workflowNodeId}, ) if err != nil { @@ -169,7 +170,7 @@ func (r *CertificateRepository) DeleteWhere(ctx context.Context, exprs ...dbx.Ex func (r *CertificateRepository) castRecordToModel(record *core.Record) (*domain.Certificate, error) { if record == nil { - return nil, fmt.Errorf("record is nil") + return nil, fmt.Errorf("the record is nil") } certificate := &domain.Certificate{ diff --git a/internal/repository/workflow.go b/internal/repository/workflow.go index 87a2e8bd0..803ddc61c 100644 --- a/internal/repository/workflow.go +++ b/internal/repository/workflow.go @@ -98,17 +98,17 @@ func (r *WorkflowRepository) Save(ctx context.Context, workflow *domain.Workflow func (r *WorkflowRepository) castRecordToModel(record *core.Record) (*domain.Workflow, error) { if record == nil { - return nil, fmt.Errorf("record is nil") + return nil, fmt.Errorf("the record is nil") } - graphDraft := &domain.WorkflowNode{} + graphDraft := &domain.WorkflowGraph{} if err := record.UnmarshalJSONField("graphDraft", graphDraft); err != nil { - return nil, err + return nil, fmt.Errorf("field 'graphDraft' is malformed") } - graphContent := &domain.WorkflowNode{} + graphContent := &domain.WorkflowGraph{} if err := record.UnmarshalJSONField("graphContent", graphContent); err != nil { - return nil, err + return nil, fmt.Errorf("field 'graphContent' is malformed") } workflow := &domain.Workflow{ diff --git a/internal/repository/workflow_log.go b/internal/repository/workflow_log.go index 948326a67..4994ba10c 100644 --- a/internal/repository/workflow_log.go +++ b/internal/repository/workflow_log.go @@ -85,12 +85,12 @@ func (r *WorkflowLogRepository) Save(ctx context.Context, workflowLog *domain.Wo func (r *WorkflowLogRepository) castRecordToModel(record *core.Record) (*domain.WorkflowLog, error) { if record == nil { - return nil, fmt.Errorf("record is nil") + return nil, fmt.Errorf("the record is nil") } logdata := make(map[string]any) if err := record.UnmarshalJSONField("data", &logdata); err != nil { - return nil, err + return nil, fmt.Errorf("field 'data' is malformed") } workflowLog := &domain.WorkflowLog{ diff --git a/internal/repository/workflow_output.go b/internal/repository/workflow_output.go index 88ff9ea73..3330bf49b 100644 --- a/internal/repository/workflow_output.go +++ b/internal/repository/workflow_output.go @@ -88,7 +88,7 @@ func (r *WorkflowOutputRepository) SaveWithCertificate(ctx context.Context, work // 写入证书 ID 到工作流输出结果中 for i, item := range workflowOutput.Outputs { - if item.Name == string(domain.WorkflowNodeIONameCertificate) { + if item.Type == "certificate" { workflowOutput.Outputs[i].Value = certificate.Id break } @@ -104,17 +104,12 @@ func (r *WorkflowOutputRepository) SaveWithCertificate(ctx context.Context, work func (r *WorkflowOutputRepository) castRecordToModel(record *core.Record) (*domain.WorkflowOutput, error) { if record == nil { - return nil, fmt.Errorf("record is nil") + return nil, fmt.Errorf("the record is nil") } - node := &domain.WorkflowNode{} - if err := record.UnmarshalJSONField("node", node); err != nil { - return nil, err - } - - outputs := make([]domain.WorkflowNodeIO, 0) + outputs := make([]*domain.WorkflowOutputEntry, 0) if err := record.UnmarshalJSONField("outputs", &outputs); err != nil { - return nil, err + return nil, fmt.Errorf("field 'outputs' is malformed") } workflowOutput := &domain.WorkflowOutput{ @@ -126,7 +121,6 @@ func (r *WorkflowOutputRepository) castRecordToModel(record *core.Record) (*doma WorkflowId: record.GetString("workflowRef"), RunId: record.GetString("runRef"), NodeId: record.GetString("nodeId"), - Node: node, Outputs: outputs, Succeeded: record.GetBool("succeeded"), } @@ -151,7 +145,6 @@ func (r *WorkflowOutputRepository) saveRecord(workflowOutput *domain.WorkflowOut record.Set("workflowRef", workflowOutput.WorkflowId) record.Set("runRef", workflowOutput.RunId) record.Set("nodeId", workflowOutput.NodeId) - record.Set("node", workflowOutput.Node) record.Set("outputs", workflowOutput.Outputs) record.Set("succeeded", workflowOutput.Succeeded) if err := app.GetApp().Save(record); err != nil { diff --git a/internal/repository/workflow_run.go b/internal/repository/workflow_run.go index 750c92a7b..cdd2dc469 100644 --- a/internal/repository/workflow_run.go +++ b/internal/repository/workflow_run.go @@ -49,6 +49,40 @@ func (r *WorkflowRunRepository) Save(ctx context.Context, workflowRun *domain.Wo } } + record.Set("workflowRef", workflowRun.WorkflowId) + record.Set("trigger", string(workflowRun.Trigger)) + record.Set("status", string(workflowRun.Status)) + record.Set("startedAt", workflowRun.StartedAt) + record.Set("endedAt", workflowRun.EndedAt) + record.Set("graph", workflowRun.Graph) + record.Set("error", workflowRun.Error) + err = app.GetApp().Save(record) + if err != nil { + return workflowRun, err + } + + return workflowRun, nil +} + +func (r *WorkflowRunRepository) SaveWithCascading(ctx context.Context, workflowRun *domain.WorkflowRun) (*domain.WorkflowRun, error) { + collection, err := app.GetApp().FindCollectionByNameOrId(domain.CollectionNameWorkflowRun) + if err != nil { + return workflowRun, err + } + + var record *core.Record + if workflowRun.Id == "" { + record = core.NewRecord(collection) + } else { + record, err = app.GetApp().FindRecordById(collection, workflowRun.Id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return workflowRun, err + } + record = core.NewRecord(collection) + } + } + err = app.GetApp().RunInTransaction(func(txApp core.App) error { record.Set("workflowRef", workflowRun.WorkflowId) record.Set("trigger", string(workflowRun.Trigger)) @@ -122,12 +156,12 @@ func (r *WorkflowRunRepository) DeleteWhere(ctx context.Context, exprs ...dbx.Ex func (r *WorkflowRunRepository) castRecordToModel(record *core.Record) (*domain.WorkflowRun, error) { if record == nil { - return nil, fmt.Errorf("record is nil") + return nil, fmt.Errorf("the record is nil") } - graph := &domain.WorkflowNode{} + graph := &domain.WorkflowGraphWithResult{} if err := record.UnmarshalJSONField("graph", &graph); err != nil { - return nil, err + return nil, fmt.Errorf("field 'graph' is malformed") } workflowRun := &domain.WorkflowRun{ diff --git a/internal/workflow/dispatcher/dispatcher.go b/internal/workflow/dispatcher/dispatcher.go index 70ff43c56..d88f89111 100644 --- a/internal/workflow/dispatcher/dispatcher.go +++ b/internal/workflow/dispatcher/dispatcher.go @@ -38,9 +38,9 @@ type workflowWorker struct { } type WorkflowWorkerData struct { - WorkflowId string - WorkflowContent *domain.WorkflowNode - RunId string + WorkflowId string + WorkflowGraph *domain.WorkflowGraph + RunId string } type WorkflowDispatcher struct { @@ -63,7 +63,11 @@ type WorkflowDispatcher struct { workflowLogRepo workflowLogRepository } -func newWorkflowDispatcher(workflowRepo workflowRepository, workflowRunRepo workflowRunRepository, workflowLogRepo workflowLogRepository) *WorkflowDispatcher { +func newWorkflowDispatcher( + workflowRepo workflowRepository, + workflowRunRepo workflowRunRepository, + workflowLogRepo workflowLogRepository, +) *WorkflowDispatcher { dispatcher := &WorkflowDispatcher{ semaphore: make(chan struct{}, maxWorkers), @@ -99,7 +103,7 @@ func newWorkflowDispatcher(workflowRepo workflowRepository, workflowRunRepo work func (d *WorkflowDispatcher) Dispatch(data *WorkflowWorkerData) { if data == nil { - panic("worker data is nil") + panic("workflow dispatcher: the worker data is nil") } d.enqueueWorker(data) @@ -135,9 +139,9 @@ func (d *WorkflowDispatcher) Cancel(runId string) { // 已挂起,查询 WorkflowRun 并更新其状态为 Canceled if !hasWorker { if run, err := d.workflowRunRepo.GetById(context.Background(), runId); err == nil { - if run.Status == domain.WorkflowRunStatusTypePending || run.Status == domain.WorkflowRunStatusTypeRunning { + if run.Status == domain.WorkflowRunStatusTypePending || run.Status == domain.WorkflowRunStatusTypeProcessing { run.Status = domain.WorkflowRunStatusTypeCanceled - d.workflowRunRepo.Save(context.Background(), run) + d.workflowRunRepo.SaveWithCascading(context.Background(), run) } } } @@ -182,6 +186,7 @@ func (d *WorkflowDispatcher) dequeueWorker() { return } + // TODO: 优化队列 data := d.queue[0] d.queue = d.queue[1:] d.queueMutex.Unlock() @@ -225,7 +230,7 @@ func (d *WorkflowDispatcher) work(ctx context.Context, data *WorkflowWorkerData) run.Status = domain.WorkflowRunStatusTypeFailed run.EndedAt = time.Now() run.Error = fmt.Sprintf("workflow run panic: %v", r) - if _, err := d.workflowRunRepo.Save(ctx, run); err != nil { + if _, err := d.workflowRunRepo.SaveWithCascading(ctx, run); err != nil { log.Default().Println("Failed to save workflow run after panic:", err) } } @@ -257,13 +262,13 @@ func (d *WorkflowDispatcher) work(ctx context.Context, data *WorkflowWorkerData) return } else if ctx.Err() != nil { run.Status = domain.WorkflowRunStatusTypeCanceled - d.workflowRunRepo.Save(ctx, run) + d.workflowRunRepo.SaveWithCascading(ctx, run) return } - // 更新 WorkflowRun 状态为 Running - run.Status = domain.WorkflowRunStatusTypeRunning - if _, err := d.workflowRunRepo.Save(ctx, run); err != nil { + // 更新 WorkflowRun 状态为 Processing + run.Status = domain.WorkflowRunStatusTypeProcessing + if _, err := d.workflowRunRepo.SaveWithCascading(ctx, run); err != nil { if !(errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) { panic(err) } @@ -281,7 +286,7 @@ func (d *WorkflowDispatcher) work(ctx context.Context, data *WorkflowWorkerData) run.Error = runErr.Error() } - if _, err := d.workflowRunRepo.Save(ctx, run); err != nil { + if _, err := d.workflowRunRepo.SaveWithCascading(ctx, run); err != nil { if !(errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) { panic(err) } @@ -298,7 +303,7 @@ func (d *WorkflowDispatcher) work(ctx context.Context, data *WorkflowWorkerData) } else { run.Status = domain.WorkflowRunStatusTypeFailed } - if _, err := d.workflowRunRepo.Save(ctx, run); err != nil { + if _, err := d.workflowRunRepo.SaveWithCascading(ctx, run); err != nil { if !(errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) { panic(err) } diff --git a/internal/workflow/dispatcher/invoker.go b/internal/workflow/dispatcher/invoker.go index 27bc439b5..b85440fe9 100644 --- a/internal/workflow/dispatcher/invoker.go +++ b/internal/workflow/dispatcher/invoker.go @@ -2,141 +2,174 @@ package dispatcher import ( "context" - "errors" "log/slog" + "time" + "github.com/certimate-go/certimate/internal/app" "github.com/certimate-go/certimate/internal/domain" - nodes "github.com/certimate-go/certimate/internal/workflow/node-processor" + "github.com/certimate-go/certimate/internal/workflow/engine" "github.com/certimate-go/certimate/pkg/logging" ) type workflowInvoker struct { - workflowId string - workflowContent *domain.WorkflowNode - runId string - logs []domain.WorkflowLog + workflowId string + workflowGraph *domain.WorkflowGraph + runId string + logs []domain.WorkflowLog workflowLogRepo workflowLogRepository } func newWorkflowInvokerWithData(workflowLogRepo workflowLogRepository, data *WorkflowWorkerData) *workflowInvoker { if data == nil { - panic("worker data is nil") + panic("workflow dispatcher: the worker data is nil") } return &workflowInvoker{ - workflowId: data.WorkflowId, - workflowContent: data.WorkflowContent, - runId: data.RunId, - logs: make([]domain.WorkflowLog, 0), + workflowId: data.WorkflowId, + workflowGraph: data.WorkflowGraph, + runId: data.RunId, + logs: make([]domain.WorkflowLog, 0), workflowLogRepo: workflowLogRepo, } } func (w *workflowInvoker) Invoke(ctx context.Context) error { - ctx = context.WithValue(ctx, "workflow_id", w.workflowId) - ctx = context.WithValue(ctx, "workflow_run_id", w.runId) - return w.processNode(ctx, w.workflowContent) + we := engine.NewWorkflowEngine() + // TODO: 优化日志记录 + we.OnNodeError(func(ctx context.Context, node *engine.Node, err error) error { + log := domain.WorkflowLog{} + log.WorkflowId = w.workflowId + log.RunId = w.runId + log.NodeId = node.Id + log.NodeName = node.Data.Name + log.Timestamp = time.Now().UnixMilli() + log.Level = int32(slog.LevelError) + log.Message = err.Error() + log.CreatedAt = time.Now() + if _, err := w.workflowLogRepo.Save(ctx, &log); err != nil { + app.GetLogger().Error(err.Error()) + } + + w.logs = append(w.logs, log) + return nil + }) + we.OnNodeLogging(func(ctx context.Context, node *engine.Node, record logging.Record) error { + log := domain.WorkflowLog{} + log.WorkflowId = w.workflowId + log.RunId = w.runId + log.NodeId = node.Id + log.NodeName = node.Data.Name + log.Timestamp = record.Time.UnixMilli() + log.Level = int32(record.Level) + log.Message = record.Message + log.Data = record.Data + log.CreatedAt = record.Time + if _, err := w.workflowLogRepo.Save(ctx, &log); err != nil { + app.GetLogger().Error(err.Error()) + } + + w.logs = append(w.logs, log) + return nil + }) + + err := we.Invoke(ctx, w.workflowId, w.runId, w.workflowGraph) + if err != nil { + return err + } + + return nil } func (w *workflowInvoker) GetLogs() domain.WorkflowLogs { return w.logs } -func (w *workflowInvoker) processNode(ctx context.Context, node *domain.WorkflowNode) error { - current := node - for current != nil { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } +func (w *workflowInvoker) processNode(ctx context.Context, graph *domain.WorkflowGraph) error { + // current := graph + // for current != nil { + // select { + // case <-ctx.Done(): + // return ctx.Err() + // default: + // } - if current.Type == domain.WorkflowNodeTypeBranch || current.Type == domain.WorkflowNodeTypeExecuteResultBranch { - for _, branch := range current.Branches { - if err := w.processNode(ctx, &branch); err != nil { - // 并行分支的某一分支发生错误时,忽略此错误,继续执行其他分支 - if !(errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) { - continue - } - return err - } - } - } + // if current.Type == domain.WorkflowNodeTypeCondition || current.Type == domain.WorkflowNodeTypeTryCatch { + // for _, branch := range current.Branches { + // if err := w.processNode(ctx, &branch); err != nil { + // // 并行分支的某一分支发生错误时,忽略此错误,继续执行其他分支 + // if !(errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) { + // continue + // } + // return err + // } + // } + // } - var processor nodes.NodeProcessor - var procErr error - for { - if current.Type != domain.WorkflowNodeTypeBranch && current.Type != domain.WorkflowNodeTypeExecuteResultBranch { - processor, procErr = nodes.GetProcessor(current) - if procErr != nil { - panic(procErr) - } + // var processor nodes.NodeProcessor + // var procErr error + // for { + // if current.Type != domain.WorkflowNodeTypeCondition && current.Type != domain.WorkflowNodeTypeTryCatch { + // processor, procErr = nodes.GetProcessor(current) + // if procErr != nil { + // panic(procErr) + // } - processor.SetLogger(slog.New(logging.NewHookHandler(&logging.HookHandlerOptions{ - Level: slog.LevelDebug, - WriteFunc: func(ctx context.Context, record *logging.Record) error { - log := domain.WorkflowLog{} - log.WorkflowId = w.workflowId - log.RunId = w.runId - log.NodeId = current.Id - log.NodeName = current.Name - log.Timestamp = record.Time.UnixMilli() - log.Level = int32(record.Level) - log.Message = record.Message - log.Data = record.Data - log.CreatedAt = record.Time - if _, err := w.workflowLogRepo.Save(ctx, &log); err != nil { - return err - } + // processor.SetLogger(slog.New(logging.NewHookHandler(&logging.HookHandlerOptions{ + // Level: slog.LevelDebug, + // WriteFunc: func(ctx context.Context, record *logging.Record) error { + // log := domain.WorkflowLog{} + // log.WorkflowId = w.workflowId + // log.RunId = w.runId + // log.NodeId = current.Id + // log.NodeName = current.Name + // log.Timestamp = record.Time.UnixMilli() + // log.Level = int32(record.Level) + // log.Message = record.Message + // log.Data = record.Data + // log.CreatedAt = record.Time + // if _, err := w.workflowLogRepo.Save(ctx, &log); err != nil { + // return err + // } - w.logs = append(w.logs, log) - return nil - }, - }))) + // w.logs = append(w.logs, log) + // return nil + // }, + // }))) - procErr = processor.Process(ctx) - if procErr != nil { - if current.Type != domain.WorkflowNodeTypeCondition { - processor.GetLogger().Error(procErr.Error()) - } - break - } + // procErr = processor.Process(ctx) + // if procErr != nil { + // if current.Type != domain.WorkflowNodeTypeBranchBlock { + // processor.GetLogger().Error(procErr.Error()) + // } + // break + // } - nodeOutputs := processor.GetOutputs() - if len(nodeOutputs) > 0 { - ctx = nodes.AddNodeOutput(ctx, current.Id, nodeOutputs) - } - } + // nodeOutputs := processor.GetOutputs() + // if len(nodeOutputs) > 0 { + // ctx = nodes.AddNodeOutput(ctx, current.Id, nodeOutputs) + // } + // } - break - } + // break + // } - // TODO: 优化可读性 - 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) - } else if procErr == nil && current.Next != nil && current.Next.Type == domain.WorkflowNodeTypeExecuteResultBranch { - current = w.getBranchByType(current.Next.Branches, domain.WorkflowNodeTypeExecuteSuccess) - } else { - current = current.Next - } - } + // // TODO: 优化可读性 + // if procErr != nil && current.Type == domain.WorkflowNodeTypeBranchBlock { + // current = nil + // procErr = nil + // return nil + // } else if procErr != nil && current.Next != nil && current.Next.Type != domain.WorkflowNodeTypeTryCatch { + // return procErr + // } else if procErr != nil && current.Next != nil && current.Next.Type == domain.WorkflowNodeTypeTryCatch { + // current = w.getBranchByType(current.Next.Branches, domain.WorkflowNodeTypeCatchBlock) + // } else if procErr == nil && current.Next != nil && current.Next.Type == domain.WorkflowNodeTypeTryCatch { + // current = w.getBranchByType(current.Next.Branches, domain.WorkflowNodeTypeTryBlock) + // } else { + // current = current.Next + // } + // } return nil } - -func (w *workflowInvoker) getBranchByType(branches []domain.WorkflowNode, nodeType domain.WorkflowNodeType) *domain.WorkflowNode { - for _, branch := range branches { - if branch.Type == nodeType { - return &branch - } - } - return nil -} diff --git a/internal/workflow/dispatcher/singleton.go b/internal/workflow/dispatcher/singleton.go index 8e5a7865a..d87b57b1d 100644 --- a/internal/workflow/dispatcher/singleton.go +++ b/internal/workflow/dispatcher/singleton.go @@ -15,7 +15,7 @@ type workflowRepository interface { type workflowRunRepository interface { GetById(ctx context.Context, id string) (*domain.WorkflowRun, error) - Save(ctx context.Context, workflowRun *domain.WorkflowRun) (*domain.WorkflowRun, error) + SaveWithCascading(ctx context.Context, workflowRun *domain.WorkflowRun) (*domain.WorkflowRun, error) } type workflowLogRepository interface { @@ -29,7 +29,11 @@ var ( func GetSingletonDispatcher() *WorkflowDispatcher { intanceOnce.Do(func() { - instance = newWorkflowDispatcher(repository.NewWorkflowRepository(), repository.NewWorkflowRunRepository(), repository.NewWorkflowLogRepository()) + instance = newWorkflowDispatcher( + repository.NewWorkflowRepository(), + repository.NewWorkflowRunRepository(), + repository.NewWorkflowLogRepository(), + ) }) return instance diff --git a/internal/workflow/engine/context.go b/internal/workflow/engine/context.go new file mode 100644 index 000000000..36b3ac875 --- /dev/null +++ b/internal/workflow/engine/context.go @@ -0,0 +1,185 @@ +package engine + +import ( + "context" + "slices" + "sync" +) + +type WorkflowContext struct { + WorkflowId string + RunId string + RunGraph *Graph + + engine WorkflowEngine + variables WorkflowIOsManager + inputs WorkflowIOsManager + + ctx context.Context +} + +func (c *WorkflowContext) SetExecutingWorkflow(workflowId string, runId string, runGraph *Graph) *WorkflowContext { + c.WorkflowId = workflowId + c.RunId = runId + c.RunGraph = runGraph + return c +} + +func (c *WorkflowContext) SetEngine(engine WorkflowEngine) *WorkflowContext { + c.engine = engine + return c +} + +func (c *WorkflowContext) SetVariablesManager(variables WorkflowIOsManager) *WorkflowContext { + c.variables = variables + return c +} + +func (c *WorkflowContext) SetInputsManager(inputs WorkflowIOsManager) *WorkflowContext { + c.inputs = inputs + return c +} + +func (c *WorkflowContext) SetContext(ctx context.Context) *WorkflowContext { + c.ctx = ctx + return c +} + +func (c *WorkflowContext) Clone() *WorkflowContext { + return &WorkflowContext{ + WorkflowId: c.WorkflowId, + RunId: c.RunId, + RunGraph: c.RunGraph, + + engine: c.engine, + variables: c.variables, + inputs: c.inputs, + + ctx: c.ctx, + } +} + +type WorkflowIOsManager interface { + All() []NodeIOEntry + Erase() + + Add(entry NodeIOEntry) + Get(name string) (*NodeIOEntry, bool) + GetScoped(scope string, name string) (*NodeIOEntry, bool) + Take(name string) (*NodeIOEntry, bool) + TakeScoped(scope string, name string) (*NodeIOEntry, bool) + Remove(name string) bool + RemoveScoped(scope string, name string) bool +} + +type workflowIOsManager struct { + entriesMtx sync.RWMutex + entries []NodeIOEntry +} + +var _ WorkflowIOsManager = (*workflowIOsManager)(nil) + +func (m *workflowIOsManager) All() []NodeIOEntry { + m.entriesMtx.RLock() + defer m.entriesMtx.RUnlock() + + if m.entries == nil { + return make([]NodeIOEntry, 0) + } + + return slices.Clone(m.entries) +} + +func (m *workflowIOsManager) Erase() { + m.entriesMtx.Lock() + defer m.entriesMtx.Unlock() + + m.entries = make([]NodeIOEntry, 0) +} + +func (m *workflowIOsManager) Add(entry NodeIOEntry) { + m.entriesMtx.Lock() + defer m.entriesMtx.Unlock() + + if m.entries == nil { + m.entries = make([]NodeIOEntry, 0) + } + + for i, item := range m.entries { + if item.Scope == entry.Scope && item.Key == entry.Key { + m.entries[i] = entry + return + } + } + m.entries = append(m.entries, entry) +} + +func (m *workflowIOsManager) Get(name string) (*NodeIOEntry, bool) { + return m.GetScoped("", name) +} + +func (m *workflowIOsManager) GetScoped(scope string, name string) (*NodeIOEntry, bool) { + m.entriesMtx.RLock() + defer m.entriesMtx.RUnlock() + + if m.entries == nil { + return nil, false + } + + for _, item := range m.entries { + if item.Scope == scope && item.Key == name { + return &item, true + } + } + return nil, false +} + +func (m *workflowIOsManager) Take(name string) (*NodeIOEntry, bool) { + return m.TakeScoped("", name) +} + +func (m *workflowIOsManager) TakeScoped(scope, name string) (*NodeIOEntry, bool) { + m.entriesMtx.Lock() + defer m.entriesMtx.Unlock() + + if m.entries == nil { + return nil, false + } + + for i, item := range m.entries { + if item.Scope == scope && item.Key == name { + m.entries = slices.Delete(m.entries, i, i+1) + return &item, true + } + } + return nil, false +} + +func (m *workflowIOsManager) Remove(name string) bool { + return m.RemoveScoped("", name) +} + +func (m *workflowIOsManager) RemoveScoped(scope string, name string) bool { + _, ok := m.TakeScoped(scope, name) + return ok +} + +func newWorkflowIOsManager() WorkflowIOsManager { + return &workflowIOsManager{ + entries: make([]NodeIOEntry, 0), + } +} + +const ( + wfIOTypeCertificate = "certificate" +) + +const ( + wfIOKeyCertificate = "certificate" +) + +const ( + wfVariableKeyCertificateValidity = "certificate.validity" // ValueType: "bool" + wfVariableKeyCertificateDaysLeft = "certificate.daysLeft" // ValueType: "int32" + wfVariableKeyNodeSkipped = "node.skipped" // ValueType: "bool" +) diff --git a/internal/workflow/engine/engine.go b/internal/workflow/engine/engine.go new file mode 100644 index 000000000..660e1ff91 --- /dev/null +++ b/internal/workflow/engine/engine.go @@ -0,0 +1,269 @@ +package engine + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync" + + "github.com/certimate-go/certimate/internal/app" + "github.com/certimate-go/certimate/pkg/logging" +) + +type WorkflowEngine interface { + Invoke(ctx context.Context, workflowId string, runId string, graph *Graph) error + + OnStart(callback func(ctx context.Context) error) + OnEnd(callback func(ctx context.Context) error) + OnError(callback func(ctx context.Context, err error) error) + OnNodeStart(callback func(ctx context.Context, node *Node) error) + OnNodeEnd(callback func(ctx context.Context, node *Node, res *NodeExecutionResult) error) + OnNodeError(callback func(ctx context.Context, node *Node, err error) error) + OnNodeLogging(callback func(ctx context.Context, node *Node, log logging.Record) error) +} + +type workflowEngine struct { + executorRegistry map[NodeType]NodeExecutor + + hooksMtx sync.RWMutex + onStartHooks [](func(ctx context.Context) error) + onEndHooks [](func(ctx context.Context) error) + onErrorHooks [](func(ctx context.Context, err error) error) + onNodeStartHooks [](func(ctx context.Context, node *Node) error) + onNodeEndHooks [](func(ctx context.Context, node *Node, res *NodeExecutionResult) error) + onNodeErrorHooks [](func(ctx context.Context, node *Node, err error) error) + onNodeLoggingHooks [](func(ctx context.Context, node *Node, log logging.Record) error) +} + +var _ WorkflowEngine = (*workflowEngine)(nil) + +func (we *workflowEngine) Invoke(ctx context.Context, workflowId string, runId string, runGraph *Graph) error { + we.fireOnStartHooks(ctx) + + defer func() { + if r := recover(); r != nil { + we.fireOnErrorHooks(ctx, fmt.Errorf("workflow engine panic: %v", r)) + } + }() + + wfCtx := (&WorkflowContext{}). + SetExecutingWorkflow(workflowId, runId, runGraph). + SetEngine(we). + SetVariablesManager(newWorkflowIOsManager()). + SetInputsManager(newWorkflowIOsManager()). + SetContext(ctx) + if err := we.executeBlocks(wfCtx, runGraph.Nodes); err != nil { + if !errors.Is(err, errInterrupted) { + we.fireOnErrorHooks(ctx, err) + return err + } + } + + we.fireOnEndHooks(ctx) + + return nil +} + +func (we *workflowEngine) OnStart(callback func(ctx context.Context) error) { + we.hooksMtx.Lock() + defer we.hooksMtx.Unlock() + we.onStartHooks = append(we.onStartHooks, callback) +} + +func (we *workflowEngine) OnEnd(callback func(ctx context.Context) error) { + we.hooksMtx.Lock() + defer we.hooksMtx.Unlock() + we.onEndHooks = append(we.onEndHooks, callback) +} + +func (we *workflowEngine) OnError(callback func(ctx context.Context, err error) error) { + we.hooksMtx.Lock() + defer we.hooksMtx.Unlock() + we.onErrorHooks = append(we.onErrorHooks, callback) +} + +func (we *workflowEngine) OnNodeStart(callback func(ctx context.Context, node *Node) error) { + we.hooksMtx.Lock() + defer we.hooksMtx.Unlock() + we.onNodeStartHooks = append(we.onNodeStartHooks, callback) +} + +func (we *workflowEngine) OnNodeEnd(callback func(ctx context.Context, node *Node, res *NodeExecutionResult) error) { + we.hooksMtx.Lock() + defer we.hooksMtx.Unlock() + we.onNodeEndHooks = append(we.onNodeEndHooks, callback) +} + +func (we *workflowEngine) OnNodeError(callback func(ctx context.Context, node *Node, err error) error) { + we.hooksMtx.Lock() + defer we.hooksMtx.Unlock() + we.onNodeErrorHooks = append(we.onNodeErrorHooks, callback) +} + +func (we *workflowEngine) OnNodeLogging(callback func(ctx context.Context, node *Node, log logging.Record) error) { + we.hooksMtx.Lock() + defer we.hooksMtx.Unlock() + we.onNodeLoggingHooks = append(we.onNodeLoggingHooks, callback) +} + +func (we *workflowEngine) executeNode(wfCtx *WorkflowContext, node *Node) error { + executor, ok := we.executorRegistry[node.Type] + if !ok { + err := fmt.Errorf("workflow engine: no executor registered for node type: '%s'", node.Type) + return err + } else { + logger := slog.New(logging.NewHookHandler(&logging.HookHandlerOptions{ + Level: slog.LevelDebug, + WriteFunc: func(ctx context.Context, record *logging.Record) error { + we.fireOnNodeLoggingHooks(ctx, node, *record) + return nil + }, + })) + executor.SetLogger(logger) + } + + execCtx := (&NodeExecutionContext{}). + SetExecutingWorkflow(wfCtx.WorkflowId, wfCtx.RunId, wfCtx.RunGraph). + SetExecutingNode(node). + SetEngine(we). + SetVariablesManager(wfCtx.variables). + SetInputsManager(wfCtx.inputs). + SetContext(wfCtx.ctx) + + we.fireOnNodeStartHooks(wfCtx.ctx, node) + + execRes, err := executor.Execute(execCtx) + if err != nil { + we.fireOnNodeErrorHooks(wfCtx.ctx, node, err) + return err + } + + we.fireOnNodeEndHooks(wfCtx.ctx, node, execRes) + + if execRes != nil { + if execRes.Variables != nil { + for _, variable := range execRes.Variables { + wfCtx.variables.Add(variable) + } + } + + if execRes.Outputs != nil { + for _, output := range execRes.Outputs { + wfCtx.inputs.Add(output) + } + } + + if execRes.Interrupted { + return errInterrupted + } + } + + return nil +} + +func (we *workflowEngine) executeBlocks(wfCtx *WorkflowContext, blocks []*Node) error { + for _, node := range blocks { + select { + case <-wfCtx.ctx.Done(): + return wfCtx.ctx.Err() + default: + } + + err := we.executeNode(wfCtx, node) + if err != nil { + return err + } + } + + return nil +} + +func (we *workflowEngine) fireOnStartHooks(ctx context.Context) { + we.hooksMtx.RLock() + defer we.hooksMtx.RUnlock() + for _, cb := range we.onStartHooks { + if cbErr := cb(ctx); cbErr != nil { + app.GetLogger().Error("workflow engine: error in onStart hook", slog.Any("error", cbErr)) + } + } +} + +func (we *workflowEngine) fireOnEndHooks(ctx context.Context) { + we.hooksMtx.RLock() + defer we.hooksMtx.RUnlock() + for _, cb := range we.onEndHooks { + if cbErr := cb(ctx); cbErr != nil { + app.GetLogger().Error("workflow engine: error in onEnd hook", slog.Any("error", cbErr)) + } + } +} + +func (we *workflowEngine) fireOnErrorHooks(ctx context.Context, err error) { + we.hooksMtx.RLock() + defer we.hooksMtx.RUnlock() + for _, cb := range we.onErrorHooks { + if cbErr := cb(ctx, err); cbErr != nil { + app.GetLogger().Error("workflow engine: error in onError hook", slog.Any("error", cbErr)) + } + } +} + +func (we *workflowEngine) fireOnNodeStartHooks(ctx context.Context, node *Node) { + we.hooksMtx.RLock() + defer we.hooksMtx.RUnlock() + for _, cb := range we.onNodeStartHooks { + if cbErr := cb(ctx, node); cbErr != nil { + app.GetLogger().Error("workflow engine: error in onNodeStart hook", slog.Any("error", cbErr)) + } + } +} + +func (we *workflowEngine) fireOnNodeEndHooks(ctx context.Context, node *Node, result *NodeExecutionResult) { + we.hooksMtx.RLock() + defer we.hooksMtx.RUnlock() + for _, cb := range we.onNodeEndHooks { + if cbErr := cb(ctx, node, result); cbErr != nil { + app.GetLogger().Error("workflow engine: error in onNodeEnd hook", slog.Any("error", cbErr)) + } + } +} + +func (we *workflowEngine) fireOnNodeErrorHooks(ctx context.Context, node *Node, err error) { + we.hooksMtx.RLock() + defer we.hooksMtx.RUnlock() + for _, cb := range we.onNodeErrorHooks { + if cbErr := cb(ctx, node, err); cbErr != nil { + app.GetLogger().Error("workflow engine: error in onNodeError hook", slog.Any("error", cbErr)) + } + } +} + +func (we *workflowEngine) fireOnNodeLoggingHooks(ctx context.Context, node *Node, log logging.Record) { + we.hooksMtx.RLock() + defer we.hooksMtx.RUnlock() + for _, cb := range we.onNodeLoggingHooks { + if cbErr := cb(ctx, node, log); cbErr != nil { + app.GetLogger().Error("workflow engine: error in onNodeLogging hook", slog.Any("error", cbErr)) + } + } +} + +func NewWorkflowEngine() WorkflowEngine { + engine := &workflowEngine{ + executorRegistry: make(map[NodeType]NodeExecutor), + } + engine.executorRegistry[NodeTypeStart] = newStartNodeExecutor() + engine.executorRegistry[NodeTypeEnd] = newEndNodeExecutor() + engine.executorRegistry[NodeTypeCondition] = newConditionNodeExecutor() + engine.executorRegistry[NodeTypeBranchBlock] = newBranchBlockNodeExecutor() + engine.executorRegistry[NodeTypeTryCatch] = newTryCatchNodeExecutor() + engine.executorRegistry[NodeTypeTryBlock] = newTryBlockNodeExecutor() + engine.executorRegistry[NodeTypeCatchBlock] = newCatchBlockNodeExecutor() + engine.executorRegistry[NodeTypeBizApply] = newBizApplyNodeExecutor() + engine.executorRegistry[NodeTypeBizUpload] = newBizUploadNodeExecutor() + engine.executorRegistry[NodeTypeBizMonitor] = newBizMonitorNodeExecutor() + engine.executorRegistry[NodeTypeBizDeploy] = newBizDeployNodeExecutor() + engine.executorRegistry[NodeTypeBizNotify] = newBizNotifyNodeExecutor() + return engine +} diff --git a/internal/workflow/engine/errors.go b/internal/workflow/engine/errors.go new file mode 100644 index 000000000..8b83d52dd --- /dev/null +++ b/internal/workflow/engine/errors.go @@ -0,0 +1,7 @@ +package engine + +import ( + "errors" +) + +var errInterrupted = errors.New("workflow engine: interrupted, may be ended") diff --git a/internal/workflow/engine/executor.go b/internal/workflow/engine/executor.go new file mode 100644 index 000000000..b664688fd --- /dev/null +++ b/internal/workflow/engine/executor.go @@ -0,0 +1,120 @@ +package engine + +import ( + "context" + "log/slog" + "sync" +) + +type NodeExecutor interface { + withLogger + + Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) +} + +type nodeExecutor struct { + logger *slog.Logger +} + +func (e *nodeExecutor) SetLogger(logger *slog.Logger) { + e.logger = logger +} + +type NodeExecutionContext struct { + WorkflowContext + + Node *Node +} + +func (c *NodeExecutionContext) SetExecutingWorkflow(workflowId string, runId string, runGraph *Graph) *NodeExecutionContext { + c.WorkflowContext.SetExecutingWorkflow(workflowId, runId, runGraph) + return c +} + +func (c *NodeExecutionContext) SetExecutingNode(node *Node) *NodeExecutionContext { + c.Node = node + return c +} + +func (c *NodeExecutionContext) SetEngine(engine WorkflowEngine) *NodeExecutionContext { + c.WorkflowContext.SetEngine(engine) + return c +} + +func (c *NodeExecutionContext) SetVariablesManager(variablesMgr WorkflowIOsManager) *NodeExecutionContext { + c.variables = variablesMgr + return c +} + +func (c *NodeExecutionContext) SetInputsManager(inputsMgr WorkflowIOsManager) *NodeExecutionContext { + c.inputs = inputsMgr + return c +} + +func (c *NodeExecutionContext) SetContext(ctx context.Context) *NodeExecutionContext { + c.WorkflowContext.SetContext(ctx) + return c +} + +type NodeExecutionResult struct { + Interrupted bool // 是否中断执行(通常由 End 节点主动触发) + + variablesMtx sync.RWMutex + Variables []NodeIOEntry + outputsMtx sync.RWMutex + Outputs []NodeIOEntry +} + +func (r *NodeExecutionResult) AddVariable(scope string, key string, value any, valueType string) { + r.AddVariableEntry(NodeIOEntry{ + Scope: scope, + Type: "", + Key: key, + Value: value, + ValueType: valueType, + }) +} + +func (r *NodeExecutionResult) AddVariableEntry(entry NodeIOEntry) { + r.variablesMtx.Lock() + defer r.variablesMtx.Unlock() + + if r.Variables == nil { + r.Variables = make([]NodeIOEntry, 0) + } + + for i, item := range r.Variables { + if item.Scope == entry.Scope && item.Key == entry.Key { + r.Variables[i] = entry + return + } + } + r.Variables = append(r.Variables, entry) +} + +func (r *NodeExecutionResult) AddOutput(scope string, type_ string, key string, value any, valueType string) { + r.AddOutputEntry(NodeIOEntry{ + Scope: scope, + Type: type_, + Key: key, + Value: value, + ValueType: valueType, + }) +} + +func (r *NodeExecutionResult) AddOutputEntry(entry NodeIOEntry) { + r.outputsMtx.Lock() + defer r.outputsMtx.Unlock() + + if r.Outputs == nil { + r.Outputs = make([]NodeIOEntry, 0) + } + + for i, t := range r.Outputs { + if t.Scope == entry.Scope && t.Key == entry.Key { + r.Outputs[i] = entry + return + } + } + r.Outputs = append(r.Outputs, entry) +} diff --git a/internal/workflow/engine/executor_bizapply.go b/internal/workflow/engine/executor_bizapply.go new file mode 100644 index 000000000..27ecf433d --- /dev/null +++ b/internal/workflow/engine/executor_bizapply.go @@ -0,0 +1,207 @@ +package engine + +import ( + "fmt" + "log/slog" + "maps" + "time" + + "github.com/certimate-go/certimate/internal/applicant" + "github.com/certimate-go/certimate/internal/domain" + "github.com/certimate-go/certimate/internal/repository" + xcert "github.com/certimate-go/certimate/pkg/utils/cert" +) + +type bizApplyNodeExecutor struct { + nodeExecutor + + certificateRepo certificateRepository + wfrunRepo workflowRunRepository + wfoutputRepo workflowOutputRepository +} + +func (ne *bizApplyNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { + execRes := &NodeExecutionResult{} + + nodeCfg := execCtx.Node.GetConfigForBizApply() + ne.logger.Info("ready to request certificate ...", slog.Any("config", nodeCfg)) + + // 查询上次执行结果 + lastOutput, lastCertificate, err := ne.getLastOutputArtifacts(execCtx) + if err != nil { + return execRes, err + } + + // 检测是否可以跳过本次执行 + if skippable, reason := ne.checkCanSkip(execCtx, lastOutput, lastCertificate); skippable { + ne.logger.Info(fmt.Sprintf("skip this application, because %s", reason)) + + execRes.AddVariable(execCtx.Node.Id, wfVariableKeyNodeSkipped, true, "boolean") + return execRes, nil + } else if reason != "" { + ne.logger.Info(fmt.Sprintf("re-apply, because %s", reason)) + } else { + ne.logger.Info("no found last issued certificate, begin to apply") + } + + // 初始化申请器 + // TODO: 解耦 + applicant, err := applicant.NewWithWorkflowNode(applicant.ApplicantWithWorkflowNodeConfig{ + WorkflowId: execCtx.WorkflowId, + Node: execCtx.Node, + Logger: ne.logger, + }) + if err != nil { + ne.logger.Warn("failed to create applicant provider") + return execRes, err + } + + // 申请证书 + applyResult, err := applicant.Apply(execCtx.ctx) + if err != nil { + ne.logger.Warn("failed to obtain certificate") + return execRes, err + } + + // 解析证书并生成实体 + certX509, err := xcert.ParseCertificateFromPEM(applyResult.FullChainCertificate) + if err != nil { + ne.logger.Warn("failed to parse certificate, may be the CA responded error") + return execRes, err + } + + certificate := &domain.Certificate{ + Source: domain.CertificateSourceTypeRequest, + Certificate: applyResult.FullChainCertificate, + PrivateKey: applyResult.PrivateKey, + IssuerCertificate: applyResult.IssuerCertificate, + ACMEAccountUrl: applyResult.ACMEAccountUrl, + ACMECertUrl: applyResult.ACMECertUrl, + ACMECertStableUrl: applyResult.ACMECertStableUrl, + } + certificate.PopulateFromX509(certX509) + + // 保存执行结果 + // TODO: 解耦 + output := &domain.WorkflowOutput{ + WorkflowId: execCtx.WorkflowId, + RunId: execCtx.RunId, + NodeId: execCtx.Node.Id, + Succeeded: true, + Outputs: []*domain.WorkflowOutputEntry{ + { + Name: "certificate", + Type: "certificate", + Value: certificate.Id, + ValueType: "string", + }, + }, + } + if _, err := ne.wfoutputRepo.SaveWithCertificate(execCtx.ctx, output, certificate); err != nil { + ne.logger.Warn("failed to save node output") + return execRes, err + } + + // 保存 ARI 记录 + if lastCertificate != nil && applyResult.ARIReplaced { + lastCertificate.ACMERenewed = true + ne.certificateRepo.Save(execCtx.ctx, lastCertificate) + } + + // 记录中间结果 + execRes.AddVariable(execCtx.Node.Id, wfVariableKeyNodeSkipped, false, "boolean") + execRes.AddVariable(execCtx.Node.Id, wfVariableKeyCertificateValidity, true, "boolean") + execRes.AddVariable(execCtx.Node.Id, wfVariableKeyCertificateDaysLeft, int32(time.Until(certificate.ValidityNotAfter).Hours()/24), "number") + + ne.logger.Info("application completed") + return execRes, nil +} + +func (ne *bizApplyNodeExecutor) getLastOutputArtifacts(execCtx *NodeExecutionContext) (*domain.WorkflowOutput, *domain.Certificate, error) { + lastOutput, err := ne.wfoutputRepo.GetByNodeId(execCtx.ctx, execCtx.Node.Id) + if err != nil && !domain.IsRecordNotFoundError(err) { + return nil, nil, fmt.Errorf("failed to get last output record of node #%s: %w", execCtx.Node.Id, err) + } + + if lastOutput != nil { + lastCertificate, err := ne.certificateRepo.GetByWorkflowRunIdAndNodeId(execCtx.ctx, lastOutput.RunId, lastOutput.NodeId) + if err != nil && !domain.IsRecordNotFoundError(err) { + return lastOutput, nil, fmt.Errorf("failed to get last certificate record of node #%s: %w", execCtx.Node.Id, err) + } + + return lastOutput, lastCertificate, nil + } + + return lastOutput, nil, nil +} + +func (ne *bizApplyNodeExecutor) checkCanSkip(execCtx *NodeExecutionContext, lastOutput *domain.WorkflowOutput, lastCertificate *domain.Certificate) (_skip bool, _reason string) { + thisNodeCfg := execCtx.Node.GetConfigForBizApply() + + if lastOutput != nil && lastOutput.Succeeded { + lastRun, err := ne.wfrunRepo.GetById(execCtx.ctx, lastOutput.RunId) + if err != nil { + return true, "failed to get last run" + } + lastNode, lastNodeExists := lastRun.Graph.GetNodeById(lastOutput.NodeId) + if !lastNodeExists { + return true, "failed to get last run node" + } + + // 比较和上次申请时的关键配置(即影响证书签发的)参数是否一致 + lastNodeCfg := lastNode.GetConfigForBizApply() + + if thisNodeCfg.Domains != lastNodeCfg.Domains { + return false, "the configuration item 'Domains' changed" + } + if thisNodeCfg.ContactEmail != lastNodeCfg.ContactEmail { + return false, "the configuration item 'ContactEmail' changed" + } + if thisNodeCfg.Provider != lastNodeCfg.Provider { + return false, "the configuration item 'Provider' changed" + } + if thisNodeCfg.ProviderAccessId != lastNodeCfg.ProviderAccessId { + return false, "the configuration item 'ProviderAccessId' changed" + } + if !maps.Equal(thisNodeCfg.ProviderConfig, lastNodeCfg.ProviderConfig) { + return false, "the configuration item 'ProviderConfig' changed" + } + if thisNodeCfg.CAProvider != lastNodeCfg.CAProvider { + return false, "the configuration item 'CAProvider' changed" + } + if thisNodeCfg.CAProviderAccessId != lastNodeCfg.CAProviderAccessId { + return false, "the configuration item 'CAProviderAccessId' changed" + } + if !maps.Equal(thisNodeCfg.CAProviderConfig, lastNodeCfg.CAProviderConfig) { + return false, "the configuration item 'CAProviderConfig' changed" + } + if thisNodeCfg.KeyAlgorithm != lastNodeCfg.KeyAlgorithm { + return false, "the configuration item 'KeyAlgorithm' changed" + } + } + + if lastCertificate != nil { + renewalInterval := time.Duration(thisNodeCfg.SkipBeforeExpiryDays) * time.Hour * 24 + expirationTime := time.Until(lastCertificate.ValidityNotAfter) + daysLeft := int(expirationTime.Hours() / 24) + if expirationTime > renewalInterval { + // TODO: 优化此处逻辑,[checkCanSkip] 方法不应该修改中间结果,违背单一职责 + // ne.outputs[variableKeyCertificateValidity] = strconv.FormatBool(true) + // ne.outputs[variableKeyCertificateDaysLeft] = strconv.FormatInt(int64(daysLeft), 10) + return true, fmt.Sprintf("the last issued certificate #%s expires in %d day(s), next renewal will be in %d day(s)", lastCertificate.Id, daysLeft, thisNodeCfg.SkipBeforeExpiryDays) + } + + return false, fmt.Sprintf("the last issued certificate #%s expires in %d day(s)", lastCertificate.Id, daysLeft) + } + + return false, "" +} + +func newBizApplyNodeExecutor() NodeExecutor { + return &bizApplyNodeExecutor{ + nodeExecutor: nodeExecutor{logger: slog.Default()}, + certificateRepo: repository.NewCertificateRepository(), + wfrunRepo: repository.NewWorkflowRunRepository(), + wfoutputRepo: repository.NewWorkflowOutputRepository(), + } +} diff --git a/internal/workflow/engine/executor_bizdeploy.go b/internal/workflow/engine/executor_bizdeploy.go new file mode 100644 index 000000000..30274e239 --- /dev/null +++ b/internal/workflow/engine/executor_bizdeploy.go @@ -0,0 +1,138 @@ +package engine + +import ( + "fmt" + "log/slog" + "maps" + + "github.com/certimate-go/certimate/internal/deployer" + "github.com/certimate-go/certimate/internal/domain" + "github.com/certimate-go/certimate/internal/repository" +) + +type bizDeployNodeExecutor struct { + nodeExecutor + + certificateRepo certificateRepository + wfrunRepo workflowRunRepository + wfoutputRepo workflowOutputRepository +} + +func (ne *bizDeployNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { + execRes := &NodeExecutionResult{} + + nodeCfg := execCtx.Node.GetConfigForBizDeploy() + ne.logger.Info("ready to deploy certificate ...", slog.Any("config", nodeCfg)) + + // 查询上次执行结果 + lastOutput, err := ne.getLastOutputArtifacts(execCtx) + if err != nil { + return execRes, err + } + + // 获取前序节点输出证书 + // TODO: 利用输入而非查库 + certificate, err := ne.certificateRepo.GetByWorkflowIdAndNodeId(execCtx.ctx, execCtx.WorkflowId, nodeCfg.CertificateOutputNodeId) + if err != nil { + ne.logger.Warn("invalid certificate output") + return execRes, err + } + + // 检测是否可以跳过本次执行 + if lastOutput != nil && certificate.CreatedAt.Before(lastOutput.UpdatedAt) { + if skippable, reason := ne.checkCanSkip(execCtx, lastOutput); skippable { + ne.logger.Info(fmt.Sprintf("skip this deployment, because %s", reason)) + + execRes.AddVariable(execCtx.Node.Id, wfVariableKeyNodeSkipped, true, "boolean") + return execRes, nil + } else if reason != "" { + ne.logger.Info(fmt.Sprintf("re-deploy, because %s", reason)) + } + } + + // 初始化部署器 + // TODO: 解耦 + deployer, err := deployer.NewWithWorkflowNode(deployer.DeployerWithWorkflowNodeConfig{ + Node: execCtx.Node, + Logger: ne.logger, + CertificatePEM: certificate.Certificate, + PrivateKeyPEM: certificate.PrivateKey, + }) + if err != nil { + ne.logger.Warn("failed to create deployer provider") + return execRes, err + } + + // 部署证书 + if err := deployer.Deploy(execCtx.ctx); err != nil { + ne.logger.Warn("failed to deploy certificate") + return execRes, err + } + + // 保存执行结果 + output := &domain.WorkflowOutput{ + WorkflowId: execCtx.WorkflowId, + RunId: execCtx.RunId, + NodeId: execCtx.Node.Id, + Succeeded: true, + } + if _, err := ne.wfoutputRepo.Save(execCtx.ctx, output); err != nil { + ne.logger.Warn("failed to save node output") + return execRes, err + } + + // 记录中间结果 + execRes.AddVariable(execCtx.Node.Id, wfVariableKeyNodeSkipped, false, "boolean") + + ne.logger.Info("deployment completed") + return execRes, nil +} + +func (ne *bizDeployNodeExecutor) getLastOutputArtifacts(execCtx *NodeExecutionContext) (*domain.WorkflowOutput, error) { + lastOutput, err := ne.wfoutputRepo.GetByNodeId(execCtx.ctx, execCtx.Node.Id) + if err != nil && !domain.IsRecordNotFoundError(err) { + return nil, fmt.Errorf("failed to get last output record of node #%s: %w", execCtx.Node.Id, err) + } + + return lastOutput, nil +} + +func (ne *bizDeployNodeExecutor) checkCanSkip(execCtx *NodeExecutionContext, lastOutput *domain.WorkflowOutput) (_skip bool, _reason string) { + thisNodeCfg := execCtx.Node.GetConfigForBizDeploy() + + if lastOutput != nil && lastOutput.Succeeded { + lastRun, err := ne.wfrunRepo.GetById(execCtx.ctx, lastOutput.RunId) + if err != nil { + return true, "failed to get last run" + } + lastNode, lastNodeExists := lastRun.Graph.GetNodeById(lastOutput.NodeId) + if !lastNodeExists { + return true, "failed to get last run node" + } + + // 比较和上次部署时的关键配置(即影响证书部署的)参数是否一致 + lastNodeCfg := lastNode.GetConfigForBizDeploy() + + if thisNodeCfg.ProviderAccessId != lastNodeCfg.ProviderAccessId { + return false, "the configuration item 'ProviderAccessId' changed" + } + if !maps.Equal(thisNodeCfg.ProviderConfig, lastNodeCfg.ProviderConfig) { + return false, "the configuration item 'ProviderConfig' changed" + } + + if thisNodeCfg.SkipOnLastSucceeded { + return true, "the last deployment already completed" + } + } + + return false, "" +} + +func newBizDeployNodeExecutor() NodeExecutor { + return &bizDeployNodeExecutor{ + nodeExecutor: nodeExecutor{logger: slog.Default()}, + certificateRepo: repository.NewCertificateRepository(), + wfrunRepo: repository.NewWorkflowRunRepository(), + wfoutputRepo: repository.NewWorkflowOutputRepository(), + } +} diff --git a/internal/workflow/node-processor/monitor_node.go b/internal/workflow/engine/executor_bizmonitor.go similarity index 50% rename from internal/workflow/node-processor/monitor_node.go rename to internal/workflow/engine/executor_bizmonitor.go index 10021eab3..78de2d954 100644 --- a/internal/workflow/node-processor/monitor_node.go +++ b/internal/workflow/engine/executor_bizmonitor.go @@ -1,7 +1,6 @@ -package nodeprocessor +package engine import ( - "context" "crypto/x509" "fmt" "log/slog" @@ -12,28 +11,22 @@ import ( "strings" "time" - "github.com/certimate-go/certimate/internal/domain" + "github.com/certimate-go/certimate/internal/repository" xhttp "github.com/certimate-go/certimate/pkg/utils/http" xtls "github.com/certimate-go/certimate/pkg/utils/tls" ) -type monitorNode struct { - node *domain.WorkflowNode - *nodeProcessor - *nodeOutputer +type bizMonitorNodeExecutor struct { + nodeExecutor + + certificateRepo certificateRepository } -func NewMonitorNode(node *domain.WorkflowNode) *monitorNode { - return &monitorNode{ - node: node, - nodeProcessor: newNodeProcessor(node), - nodeOutputer: newNodeOutputer(), - } -} +func (ne *bizMonitorNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { + execRes := &NodeExecutionResult{} -func (n *monitorNode) Process(ctx context.Context) error { - nodeCfg := n.node.GetConfigForMonitor() - n.logger.Info("ready to monitor certificate ...", slog.Any("config", nodeCfg)) + nodeCfg := execCtx.Node.GetConfigForBizMonitor() + ne.logger.Info("ready to monitor certificate ...", slog.Any("config", nodeCfg)) targetAddr := net.JoinHostPort(nodeCfg.Host, strconv.Itoa(int(nodeCfg.Port))) if nodeCfg.Port == 0 { @@ -45,41 +38,41 @@ func (n *monitorNode) Process(ctx context.Context) error { targetDomain = nodeCfg.Host } - n.logger.Info(fmt.Sprintf("retrieving certificate at %s (domain: %s)", targetAddr, targetDomain)) + ne.logger.Info(fmt.Sprintf("retrieving certificate at %s (domain: %s)", targetAddr, targetDomain)) const MAX_ATTEMPTS = 3 const RETRY_INTERVAL = 2 * time.Second - var certs []*x509.Certificate var err error + var certs []*x509.Certificate for attempt := 0; attempt < MAX_ATTEMPTS; attempt++ { if attempt > 0 { - n.logger.Info(fmt.Sprintf("retry %d time(s) ...", attempt, targetAddr)) + ne.logger.Info(fmt.Sprintf("retry %d time(s) ...", attempt, targetAddr)) select { - case <-ctx.Done(): - return ctx.Err() + case <-execCtx.ctx.Done(): + return execRes, execCtx.ctx.Err() case <-time.After(RETRY_INTERVAL): } } - certs, err = n.tryRetrievePeerCertificates(ctx, targetAddr, targetDomain, nodeCfg.RequestPath) + certs, err = ne.tryRetrievePeerCertificates(execCtx, targetAddr, targetDomain, nodeCfg.RequestPath) if err == nil { break } } if err != nil { - n.logger.Warn("failed to monitor certificate") - return err + ne.logger.Warn("failed to monitor certificate") + return execRes, err } else { if len(certs) == 0 { - n.logger.Warn("no ssl certificates retrieved in http response") + ne.logger.Warn("no ssl certificates retrieved in http response") - n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(false) - n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(0, 10) + execRes.AddVariable(execCtx.Node.Id, wfVariableKeyCertificateValidity, false, "boolean") + execRes.AddVariable(execCtx.Node.Id, wfVariableKeyCertificateDaysLeft, 0, "number") } 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')", + ne.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, ";")), @@ -94,28 +87,28 @@ func (n *monitorNode) Process(ctx context.Context) error { validated := isCertPeriodValid && isCertHostMatched daysLeft := int(math.Floor(cert.NotAfter.Sub(now).Hours() / 24)) - n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(validated) - n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(daysLeft), 10) + execRes.AddVariable(execCtx.Node.Id, wfVariableKeyCertificateValidity, validated, "boolean") + execRes.AddVariable(execCtx.Node.Id, wfVariableKeyCertificateDaysLeft, daysLeft, "number") if validated { - n.logger.Info(fmt.Sprintf("the certificate is valid, and will expire in %d day(s)", daysLeft)) + ne.logger.Info(fmt.Sprintf("the certificate is valid, and will expire in %d day(s)", daysLeft)) } else { if !isCertHostMatched { - n.logger.Warn("the certificate is invalid, because it is not matched the host") + ne.logger.Warn("the certificate is invalid, because it is not matched the host") } else if !isCertPeriodValid { - n.logger.Warn("the certificate is invalid, because it is either expired or not yet valid") + ne.logger.Warn("the certificate is invalid, because it is either expired or not yet valid") } else { - n.logger.Warn("the certificate is invalid") + ne.logger.Warn("the certificate is invalid") } } } } - n.logger.Info("monitoring completed") - return nil + ne.logger.Info("monitoring completed") + return execRes, nil } -func (n *monitorNode) tryRetrievePeerCertificates(ctx context.Context, addr, domain, requestPath string) ([]*x509.Certificate, error) { +func (ne *bizMonitorNodeExecutor) tryRetrievePeerCertificates(execCtx *NodeExecutionContext, addr, domain, requestPath string) ([]*x509.Certificate, error) { transport := xhttp.NewDefaultTransport() transport.TLSClientConfig = xtls.NewInsecureConfig() @@ -128,10 +121,10 @@ func (n *monitorNode) tryRetrievePeerCertificates(ctx context.Context, addr, dom } url := fmt.Sprintf("https://%s/%s", addr, strings.TrimLeft(requestPath, "/")) - req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) + req, err := http.NewRequestWithContext(execCtx.ctx, http.MethodHead, url, nil) if err != nil { err = fmt.Errorf("failed to create http request: %w", err) - n.logger.Warn(err.Error()) + ne.logger.Warn(err.Error()) return nil, err } @@ -140,7 +133,7 @@ func (n *monitorNode) tryRetrievePeerCertificates(ctx context.Context, addr, dom resp, err := client.Do(req) if err != nil { err = fmt.Errorf("failed to send http request: %w", err) - n.logger.Warn(err.Error()) + ne.logger.Warn(err.Error()) return nil, err } defer resp.Body.Close() @@ -150,3 +143,10 @@ func (n *monitorNode) tryRetrievePeerCertificates(ctx context.Context, addr, dom } return resp.TLS.PeerCertificates, nil } + +func newBizMonitorNodeExecutor() NodeExecutor { + return &bizMonitorNodeExecutor{ + nodeExecutor: nodeExecutor{logger: slog.Default()}, + certificateRepo: repository.NewCertificateRepository(), + } +} diff --git a/internal/workflow/engine/executor_biznotify.go b/internal/workflow/engine/executor_biznotify.go new file mode 100644 index 000000000..194831f23 --- /dev/null +++ b/internal/workflow/engine/executor_biznotify.go @@ -0,0 +1,74 @@ +package engine + +import ( + "fmt" + "log/slog" + + "github.com/certimate-go/certimate/internal/notify" + "github.com/certimate-go/certimate/internal/repository" +) + +type bizNotifyNodeExecutor struct { + nodeExecutor + + settingsRepo settingsRepository +} + +func (ne *bizNotifyNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { + execRes := &NodeExecutionResult{} + + nodeCfg := execCtx.Node.GetConfigForBizNotify() + ne.logger.Info("ready to send notification ...", slog.Any("config", nodeCfg)) + + // 检测是否可以跳过本次执行 + if skippable := ne.checkCanSkip(execCtx); skippable { + ne.logger.Info(fmt.Sprintf("skip this notification, because all the previous nodes have been skipped")) + return execRes, nil + } + + // 初始化通知器 + deployer, err := notify.NewWithWorkflowNode(notify.NotifierWithWorkflowNodeConfig{ + Node: execCtx.Node, + Logger: ne.logger, + Subject: nodeCfg.Subject, + Message: nodeCfg.Message, + }) + if err != nil { + ne.logger.Warn("failed to create notifier provider") + return execRes, err + } + + // 推送通知 + if err := deployer.Notify(execCtx.ctx); err != nil { + ne.logger.Warn("failed to send notification") + return execRes, err + } + + ne.logger.Info("notification completed") + return execRes, nil +} + +func (ne *bizNotifyNodeExecutor) checkCanSkip(execCtx *NodeExecutionContext) (_skip bool) { + thisNodeCfg := execCtx.Node.GetConfigForBizNotify() + if !thisNodeCfg.SkipOnAllPrevSkipped { + return false + } + + var total, skipped int32 + for _, variable := range execCtx.variables.All() { + if variable.Scope != "" && variable.Key == wfVariableKeyNodeSkipped { + total++ + if variable.Value == true { + skipped++ + } + } + } + return total > 0 && skipped == total +} + +func newBizNotifyNodeExecutor() NodeExecutor { + return &bizNotifyNodeExecutor{ + nodeExecutor: nodeExecutor{logger: slog.Default()}, + settingsRepo: repository.NewSettingsRepository(), + } +} diff --git a/internal/workflow/engine/executor_bizupload.go b/internal/workflow/engine/executor_bizupload.go new file mode 100644 index 000000000..d6def222d --- /dev/null +++ b/internal/workflow/engine/executor_bizupload.go @@ -0,0 +1,141 @@ +package engine + +import ( + "fmt" + "log/slog" + "strings" + "time" + + "github.com/certimate-go/certimate/internal/domain" + "github.com/certimate-go/certimate/internal/repository" +) + +type bizUploadNodeExecutor struct { + nodeExecutor + + certificateRepo certificateRepository + wfrunRepo workflowRunRepository + wfoutputRepo workflowOutputRepository +} + +func (ne *bizUploadNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { + execRes := &NodeExecutionResult{} + + nodeCfg := execCtx.Node.GetConfigForBizUpload() + ne.logger.Info("ready to upload certiticate ...", slog.Any("config", nodeCfg)) + + // 查询上次执行结果 + lastOutput, lastCertificate, err := ne.getLastOutputArtifacts(execCtx) + if err != nil { + return execRes, err + } + + // 检测是否可以跳过本次执行 + if skippable, reason := ne.checkCanSkip(execCtx, lastOutput, lastCertificate); skippable { + ne.logger.Info(fmt.Sprintf("skip this uploading, because %s", reason)) + + execRes.AddVariable(execCtx.Node.Id, wfVariableKeyNodeSkipped, true, "boolean") + return execRes, nil + } else if reason != "" { + ne.logger.Info(fmt.Sprintf("re-upload, because %s", reason)) + } else { + ne.logger.Info("no found last uploaded certificate, begin to upload") + } + + // 生成证书实体 + certificate := &domain.Certificate{ + Source: domain.CertificateSourceTypeUpload, + } + certificate.PopulateFromPEM(nodeCfg.Certificate, nodeCfg.PrivateKey) + + // 保存执行结果 + // TODO: 解耦 + output := &domain.WorkflowOutput{ + WorkflowId: execCtx.WorkflowId, + RunId: execCtx.RunId, + NodeId: execCtx.Node.Id, + Succeeded: true, + Outputs: []*domain.WorkflowOutputEntry{ + { + Name: "certificate", + Type: "certificate", + Value: certificate.Id, + ValueType: "string", + }, + }, + } + if _, err := ne.wfoutputRepo.SaveWithCertificate(execCtx.ctx, output, certificate); err != nil { + ne.logger.Warn("failed to save node output") + return execRes, err + } + + // 记录中间结果 + execRes.AddVariable(execCtx.Node.Id, wfVariableKeyNodeSkipped, false, "boolean") + execRes.AddVariable(execCtx.Node.Id, wfVariableKeyCertificateValidity, true, "boolean") + execRes.AddVariable(execCtx.Node.Id, wfVariableKeyCertificateDaysLeft, int32(time.Until(certificate.ValidityNotAfter).Hours()/24), "number") + + ne.logger.Info("uploading completed") + return execRes, nil +} + +func (ne *bizUploadNodeExecutor) getLastOutputArtifacts(execCtx *NodeExecutionContext) (*domain.WorkflowOutput, *domain.Certificate, error) { + lastOutput, err := ne.wfoutputRepo.GetByNodeId(execCtx.ctx, execCtx.Node.Id) + if err != nil && !domain.IsRecordNotFoundError(err) { + return nil, nil, fmt.Errorf("failed to get last output record of node #%s: %w", execCtx.Node.Id, err) + } + + if lastOutput != nil { + lastCertificate, err := ne.certificateRepo.GetByWorkflowRunIdAndNodeId(execCtx.ctx, lastOutput.RunId, lastOutput.NodeId) + if err != nil && !domain.IsRecordNotFoundError(err) { + return lastOutput, nil, fmt.Errorf("failed to get last certificate record of node #%s: %w", execCtx.Node.Id, err) + } + + return lastOutput, lastCertificate, nil + } + + return lastOutput, nil, nil +} + +func (ne *bizUploadNodeExecutor) checkCanSkip(execCtx *NodeExecutionContext, lastOutput *domain.WorkflowOutput, lastCertificate *domain.Certificate) (_skip bool, _reason string) { + thisNodeCfg := execCtx.Node.GetConfigForBizUpload() + + if lastOutput != nil && lastOutput.Succeeded { + lastRun, err := ne.wfrunRepo.GetById(execCtx.ctx, lastOutput.RunId) + if err != nil { + return true, "failed to get last run" + } + lastNode, lastNodeExists := lastRun.Graph.GetNodeById(lastOutput.NodeId) + if !lastNodeExists { + return true, "failed to get last run node" + } + + // 比较和上次上传时的关键配置(即影响证书上传的)参数是否一致 + lastNodeCfg := lastNode.GetConfigForBizUpload() + + if strings.TrimSpace(thisNodeCfg.Certificate) != strings.TrimSpace(lastNodeCfg.Certificate) { + return false, "the configuration item 'Certificate' changed" + } + if strings.TrimSpace(thisNodeCfg.PrivateKey) != strings.TrimSpace(lastNodeCfg.PrivateKey) { + return false, "the configuration item 'PrivateKey' changed" + } + } + + if lastCertificate != nil { + // TODO: 优化此处逻辑,[checkCanSkip] 方法不应该修改中间结果,违背单一职责 + // daysLeft := int(time.Until(lastCertificate.ValidityNotAfter).Hours() / 24) + // execCtx.SetInputEntry(execCtx.Node.Id, variableKeyCertificateValidity, strconv.FormatBool(daysLeft > 0)) + // execCtx.SetInputEntry(execCtx.Node.Id, variableKeyCertificateDaysLeft, strconv.FormatInt(int64(daysLeft), 10)) + return true, "the last uploaded certificate already exists" + } + + return false, "" +} + +func newBizUploadNodeExecutor() NodeExecutor { + return &bizUploadNodeExecutor{ + nodeExecutor: nodeExecutor{logger: slog.Default()}, + certificateRepo: repository.NewCertificateRepository(), + wfrunRepo: repository.NewWorkflowRunRepository(), + wfoutputRepo: repository.NewWorkflowOutputRepository(), + } +} diff --git a/internal/workflow/engine/executor_condition.go b/internal/workflow/engine/executor_condition.go new file mode 100644 index 000000000..adcb3e7d4 --- /dev/null +++ b/internal/workflow/engine/executor_condition.go @@ -0,0 +1,116 @@ +package engine + +import ( + "errors" + "fmt" + "log/slog" + "strconv" + + "github.com/samber/lo" +) + +type conditionNodeExecutor struct { + nodeExecutor +} + +func (ne *conditionNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { + var engine *workflowEngine + if we, ok := execCtx.engine.(*workflowEngine); !ok { + panic("impossible!") + } else { + engine = we + } + + execRes := &NodeExecutionResult{} + + errs := make([]error, 0) + blocks := lo.Filter(execCtx.Node.Blocks, func(n *Node, _ int) bool { return n.Type == NodeTypeBranchBlock }) + for _, node := range blocks { + select { + case <-execCtx.ctx.Done(): + return execRes, execCtx.ctx.Err() + default: + } + + err := engine.executeNode(execCtx.Clone(), node) + if err != nil { + if errors.Is(err, errInterrupted) { + return execRes, err + } + errs = append(errs, err) + } + } + + if len(errs) > 0 { + return execRes, errors.Join(errs...) + } + + return execRes, nil +} + +func newConditionNodeExecutor() NodeExecutor { + return &conditionNodeExecutor{ + nodeExecutor: nodeExecutor{logger: slog.Default()}, + } +} + +type branchBlockNodeExecutor struct { + nodeExecutor +} + +func (ne *branchBlockNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { + execRes := &NodeExecutionResult{} + + nodeCfg := execCtx.Node.GetConfigForBranchBlock() + if nodeCfg.Expression == nil { + ne.logger.Info("enter this branch without any conditions") + } else { + variables := lo.Reduce(execCtx.variables.All(), func(acc map[string]map[string]any, entry NodeIOEntry, _ int) map[string]map[string]any { + if _, ok := acc[entry.Scope]; !ok { + acc[entry.Scope] = make(map[string]any) + } + + // 这里需要把所有值都转换为字符串形式,因为 Expression.Eval 仅支持字符串类型的值 + var value any + if entry.ValueType == "number" { + value = fmt.Sprintf("%d", entry.Value) + } else if entry.ValueType == "boolean" { + value = strconv.FormatBool(entry.Value.(bool)) + } else { + value = entry.Value + } + acc[entry.Scope][entry.Key] = value + + return acc + }, make(map[string]map[string]any)) + + rs, err := nodeCfg.Expression.Eval(variables) + if err != nil { + ne.logger.Warn(fmt.Sprintf("failed to eval expr: %+v", err)) + return execRes, err + } + + if rs.Value == false { + ne.logger.Info("skip this branch, because condition not met") + return execRes, nil + } else { + ne.logger.Info("enter this branch, because condition met") + } + } + + if engine, ok := execCtx.engine.(*workflowEngine); !ok { + panic("impossible!") + } else { + if err := engine.executeBlocks(execCtx.Clone(), execCtx.Node.Blocks); err != nil { + return execRes, err + } + } + + return execRes, nil +} + +func newBranchBlockNodeExecutor() NodeExecutor { + return &branchBlockNodeExecutor{ + nodeExecutor: nodeExecutor{logger: slog.Default()}, + } +} diff --git a/internal/workflow/engine/executor_end.go b/internal/workflow/engine/executor_end.go new file mode 100644 index 000000000..ac440f432 --- /dev/null +++ b/internal/workflow/engine/executor_end.go @@ -0,0 +1,22 @@ +package engine + +import ( + "log/slog" +) + +type endNodeExecutor struct { + nodeExecutor +} + +func (e *endNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { + execRes := &NodeExecutionResult{} + execRes.Interrupted = true + + return execRes, nil +} + +func newEndNodeExecutor() NodeExecutor { + return &endNodeExecutor{ + nodeExecutor: nodeExecutor{logger: slog.Default()}, + } +} diff --git a/internal/workflow/engine/executor_start.go b/internal/workflow/engine/executor_start.go new file mode 100644 index 000000000..e5db453f1 --- /dev/null +++ b/internal/workflow/engine/executor_start.go @@ -0,0 +1,21 @@ +package engine + +import ( + "log/slog" +) + +type startNodeExecutor struct { + nodeExecutor +} + +func (e *startNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { + execRes := &NodeExecutionResult{} + + return execRes, nil +} + +func newStartNodeExecutor() NodeExecutor { + return &startNodeExecutor{ + nodeExecutor: nodeExecutor{logger: slog.Default()}, + } +} diff --git a/internal/workflow/engine/executor_trycatch.go b/internal/workflow/engine/executor_trycatch.go new file mode 100644 index 000000000..f562981eb --- /dev/null +++ b/internal/workflow/engine/executor_trycatch.go @@ -0,0 +1,129 @@ +package engine + +import ( + "errors" + "log/slog" + + "github.com/samber/lo" +) + +type tryCatchNodeExecutor struct { + nodeExecutor +} + +func (ne *tryCatchNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { + var engine *workflowEngine + if we, ok := execCtx.engine.(*workflowEngine); !ok { + panic("impossible!") + } else { + engine = we + } + + execRes := &NodeExecutionResult{} + + tryErrs := make([]error, 0) + tryBlocks := lo.Filter(execCtx.Node.Blocks, func(n *Node, _ int) bool { return n.Type == NodeTypeTryBlock }) + for _, node := range tryBlocks { + select { + case <-execCtx.ctx.Done(): + return execRes, execCtx.ctx.Err() + default: + } + + err := engine.executeNode(execCtx.Clone(), node) + if err != nil { + if errors.Is(err, errInterrupted) { + return execRes, err + } + tryErrs = append(tryErrs, err) + } + } + + if len(tryErrs) > 0 { + catchErrs := make([]error, 0) + catchBlocks := lo.Filter(execCtx.Node.Blocks, func(n *Node, _ int) bool { return n.Type == NodeTypeCatchBlock }) + for _, node := range catchBlocks { + select { + case <-execCtx.ctx.Done(): + return execRes, execCtx.ctx.Err() + default: + } + + err := engine.executeNode(execCtx.Clone(), node) + if err != nil { + if errors.Is(err, errInterrupted) { + return execRes, err + } + catchErrs = append(catchErrs, err) + } + } + + if len(catchErrs) > 0 { + return execRes, errors.Join(append(tryErrs, catchErrs...)...) + } + + return execRes, errors.Join(tryErrs...) + } + + return execRes, nil +} + +func newTryCatchNodeExecutor() NodeExecutor { + return &tryCatchNodeExecutor{ + nodeExecutor: nodeExecutor{logger: slog.Default()}, + } +} + +type tryBlockNodeExecutor struct { + nodeExecutor +} + +func (ne *tryBlockNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { + var engine *workflowEngine + if we, ok := execCtx.engine.(*workflowEngine); !ok { + panic("impossible!") + } else { + engine = we + } + + execRes := &NodeExecutionResult{} + + if err := engine.executeBlocks(execCtx.Clone(), execCtx.Node.Blocks); err != nil { + return execRes, err + } + + return execRes, nil +} + +func newTryBlockNodeExecutor() NodeExecutor { + return &tryBlockNodeExecutor{ + nodeExecutor: nodeExecutor{logger: slog.Default()}, + } +} + +type catchBlockNodeExecutor struct { + nodeExecutor +} + +func (ne *catchBlockNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { + execRes := &NodeExecutionResult{} + + var engine *workflowEngine + if we, ok := execCtx.engine.(*workflowEngine); !ok { + panic("impossible!") + } else { + engine = we + } + + if err := engine.executeBlocks(execCtx.Clone(), execCtx.Node.Blocks); err != nil { + return execRes, err + } + + return execRes, nil +} + +func newCatchBlockNodeExecutor() NodeExecutor { + return &catchBlockNodeExecutor{ + nodeExecutor: nodeExecutor{logger: slog.Default()}, + } +} diff --git a/internal/workflow/engine/logger.go b/internal/workflow/engine/logger.go new file mode 100644 index 000000000..6997ffb3b --- /dev/null +++ b/internal/workflow/engine/logger.go @@ -0,0 +1,9 @@ +package engine + +import ( + "log/slog" +) + +type withLogger interface { + SetLogger(logger *slog.Logger) +} diff --git a/internal/workflow/engine/models.go b/internal/workflow/engine/models.go new file mode 100644 index 000000000..63765f26c --- /dev/null +++ b/internal/workflow/engine/models.go @@ -0,0 +1,34 @@ +package engine + +import ( + "github.com/certimate-go/certimate/internal/domain" +) + +type Node = domain.WorkflowNode + +type NodeType = domain.WorkflowNodeType + +const ( + NodeTypeStart = domain.WorkflowNodeTypeStart + NodeTypeEnd = domain.WorkflowNodeTypeEnd + NodeTypeCondition = domain.WorkflowNodeTypeCondition + NodeTypeBranchBlock = domain.WorkflowNodeTypeBranchBlock + NodeTypeTryCatch = domain.WorkflowNodeTypeTryCatch + NodeTypeTryBlock = domain.WorkflowNodeTypeTryBlock + NodeTypeCatchBlock = domain.WorkflowNodeTypeCatchBlock + NodeTypeBizApply = domain.WorkflowNodeTypeBizApply + NodeTypeBizUpload = domain.WorkflowNodeTypeBizUpload + NodeTypeBizMonitor = domain.WorkflowNodeTypeBizMonitor + NodeTypeBizDeploy = domain.WorkflowNodeTypeBizDeploy + NodeTypeBizNotify = domain.WorkflowNodeTypeBizNotify +) + +type NodeIOEntry struct { + Scope string // 零值时表示全局的,否则表示指定节点的 + Type string // 仅表示输入输出有值,表示变量无值 + Key string + Value any + ValueType string `options:"string | number | boolean"` +} + +type Graph = domain.WorkflowGraph diff --git a/internal/workflow/engine/services.go b/internal/workflow/engine/services.go new file mode 100644 index 000000000..14105abc5 --- /dev/null +++ b/internal/workflow/engine/services.go @@ -0,0 +1,27 @@ +package engine + +import ( + "context" + + "github.com/certimate-go/certimate/internal/domain" +) + +type certificateRepository interface { + GetByWorkflowIdAndNodeId(ctx context.Context, workflowId string, workflowNodeId string) (*domain.Certificate, error) + GetByWorkflowRunIdAndNodeId(ctx context.Context, workflowRunId string, workflowNodeId string) (*domain.Certificate, error) + Save(ctx context.Context, certificate *domain.Certificate) (*domain.Certificate, error) +} + +type workflowRunRepository interface { + GetById(ctx context.Context, workflowRunId string) (*domain.WorkflowRun, error) +} + +type workflowOutputRepository interface { + GetByNodeId(ctx context.Context, workflowNodeId string) (*domain.WorkflowOutput, error) + Save(ctx context.Context, workflowOutput *domain.WorkflowOutput) (*domain.WorkflowOutput, error) + SaveWithCertificate(ctx context.Context, workflowOutput *domain.WorkflowOutput, certificate *domain.Certificate) (*domain.WorkflowOutput, error) +} + +type settingsRepository interface { + GetByName(ctx context.Context, name string) (*domain.Settings, error) +} diff --git a/internal/workflow/event.go b/internal/workflow/event.go index a8a04e47c..1af72a9ef 100644 --- a/internal/workflow/event.go +++ b/internal/workflow/event.go @@ -13,35 +13,38 @@ import ( ) func Register() { - app := app.GetApp() - app.OnRecordCreateRequest(domain.CollectionNameWorkflow).BindFunc(func(e *core.RecordRequestEvent) error { + pb := app.GetApp() + pb.OnRecordCreateRequest(domain.CollectionNameWorkflow).BindFunc(func(e *core.RecordRequestEvent) error { if err := e.Next(); err != nil { return err } if err := onWorkflowRecordCreateOrUpdate(e.Request.Context(), e.Record); err != nil { + app.GetLogger().Error(err.Error()) return err } return nil }) - app.OnRecordUpdateRequest(domain.CollectionNameWorkflow).BindFunc(func(e *core.RecordRequestEvent) error { + pb.OnRecordUpdateRequest(domain.CollectionNameWorkflow).BindFunc(func(e *core.RecordRequestEvent) error { if err := e.Next(); err != nil { return err } if err := onWorkflowRecordCreateOrUpdate(e.Request.Context(), e.Record); err != nil { + app.GetLogger().Error(err.Error()) return err } return nil }) - app.OnRecordDeleteRequest(domain.CollectionNameWorkflow).BindFunc(func(e *core.RecordRequestEvent) error { + pb.OnRecordDeleteRequest(domain.CollectionNameWorkflow).BindFunc(func(e *core.RecordRequestEvent) error { if err := e.Next(); err != nil { return err } if err := onWorkflowRecordDelete(e.Request.Context(), e.Record); err != nil { + app.GetLogger().Error(err.Error()) return err } diff --git a/internal/workflow/node-processor/apply_node.go b/internal/workflow/node-processor/apply_node.go deleted file mode 100644 index 2878daee9..000000000 --- a/internal/workflow/node-processor/apply_node.go +++ /dev/null @@ -1,174 +0,0 @@ -package nodeprocessor - -import ( - "context" - "fmt" - "log/slog" - "strconv" - "time" - - "golang.org/x/exp/maps" - - "github.com/certimate-go/certimate/internal/applicant" - "github.com/certimate-go/certimate/internal/domain" - "github.com/certimate-go/certimate/internal/repository" - xcert "github.com/certimate-go/certimate/pkg/utils/cert" -) - -type applyNode struct { - node *domain.WorkflowNode - *nodeProcessor - *nodeOutputer - - certRepo certificateRepository - outputRepo workflowOutputRepository -} - -func NewApplyNode(node *domain.WorkflowNode) *applyNode { - return &applyNode{ - node: node, - nodeProcessor: newNodeProcessor(node), - nodeOutputer: newNodeOutputer(), - - certRepo: repository.NewCertificateRepository(), - outputRepo: repository.NewWorkflowOutputRepository(), - } -} - -func (n *applyNode) Process(ctx context.Context) error { - nodeCfg := n.node.GetConfigForApply() - n.logger.Info("ready to obtain certificate ...", slog.Any("config", nodeCfg)) - - // 查询上次执行结果 - lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id) - if err != nil && !domain.IsRecordNotFoundError(err) { - return err - } - - // 检测是否可以跳过本次执行 - if skippable, reason := n.checkCanSkip(ctx, lastOutput); skippable { - n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(true) - n.logger.Info(fmt.Sprintf("skip this application, because %s", reason)) - return nil - } else if reason != "" { - n.logger.Info(fmt.Sprintf("re-apply, because %s", reason)) - } - - // 初始化申请器 - applicant, err := applicant.NewWithWorkflowNode(applicant.ApplicantWithWorkflowNodeConfig{ - Node: n.node, - Logger: n.logger, - }) - if err != nil { - n.logger.Warn("failed to create applicant provider") - return err - } - - // 申请证书 - applyResult, err := applicant.Apply(ctx) - if err != nil { - n.logger.Warn("failed to obtain certificate") - return err - } - - // 解析证书并生成实体 - certX509, err := xcert.ParseCertificateFromPEM(applyResult.FullChainCertificate) - if err != nil { - n.logger.Warn("failed to parse certificate, may be the CA responded error") - return err - } - - certificate := &domain.Certificate{ - Source: domain.CertificateSourceTypeRequest, - Certificate: applyResult.FullChainCertificate, - PrivateKey: applyResult.PrivateKey, - IssuerCertificate: applyResult.IssuerCertificate, - ACMEAccountUrl: applyResult.ACMEAccountUrl, - ACMECertUrl: applyResult.ACMECertUrl, - ACMECertStableUrl: applyResult.ACMECertStableUrl, - } - certificate.PopulateFromX509(certX509) - - // 保存执行结果 - output := &domain.WorkflowOutput{ - WorkflowId: getContextWorkflowId(ctx), - RunId: getContextWorkflowRunId(ctx), - NodeId: n.node.Id, - Node: n.node, - Succeeded: true, - Outputs: n.node.Outputs, - } - if _, err := n.outputRepo.SaveWithCertificate(ctx, output, certificate); err != nil { - n.logger.Warn("failed to save node output") - return err - } - - // 保存 ARI 记录 - if applyResult.ARIReplaced && lastOutput != nil { - lastCertificate, _ := n.certRepo.GetByWorkflowRunIdAndNodeId(ctx, lastOutput.RunId, lastOutput.NodeId) - if lastCertificate != nil { - lastCertificate.ACMERenewed = true - n.certRepo.Save(ctx, lastCertificate) - } - } - - // 记录中间结果 - n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(false) - n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(true) - n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(time.Until(certificate.ValidityNotAfter).Hours()/24), 10) - - n.logger.Info("application completed") - return nil -} - -func (n *applyNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (_skip bool, _reason string) { - if lastOutput != nil && lastOutput.Succeeded { - // 比较和上次申请时的关键配置(即影响证书签发的)参数是否一致 - thisNodeCfg := n.node.GetConfigForApply() - lastNodeCfg := lastOutput.Node.GetConfigForApply() - - if thisNodeCfg.Domains != lastNodeCfg.Domains { - return false, "the configuration item 'Domains' changed" - } - if thisNodeCfg.ContactEmail != lastNodeCfg.ContactEmail { - return false, "the configuration item 'ContactEmail' changed" - } - if thisNodeCfg.Provider != lastNodeCfg.Provider { - return false, "the configuration item 'Provider' changed" - } - if thisNodeCfg.ProviderAccessId != lastNodeCfg.ProviderAccessId { - return false, "the configuration item 'ProviderAccessId' changed" - } - if !maps.Equal(thisNodeCfg.ProviderConfig, lastNodeCfg.ProviderConfig) { - return false, "the configuration item 'ProviderConfig' changed" - } - if thisNodeCfg.CAProvider != lastNodeCfg.CAProvider { - return false, "the configuration item 'CAProvider' changed" - } - if thisNodeCfg.CAProviderAccessId != lastNodeCfg.CAProviderAccessId { - return false, "the configuration item 'CAProviderAccessId' changed" - } - if !maps.Equal(thisNodeCfg.CAProviderConfig, lastNodeCfg.CAProviderConfig) { - return false, "the configuration item 'CAProviderConfig' changed" - } - if thisNodeCfg.KeyAlgorithm != lastNodeCfg.KeyAlgorithm { - return false, "the configuration item 'KeyAlgorithm' changed" - } - - lastCertificate, _ := n.certRepo.GetByWorkflowRunIdAndNodeId(ctx, lastOutput.RunId, lastOutput.NodeId) - if lastCertificate != nil { - renewalInterval := time.Duration(thisNodeCfg.SkipBeforeExpiryDays) * time.Hour * 24 - expirationTime := time.Until(lastCertificate.ValidityNotAfter) - if expirationTime > renewalInterval { - 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, thisNodeCfg.SkipBeforeExpiryDays) - } - } - } - - return false, "" -} diff --git a/internal/workflow/node-processor/condition_node.go b/internal/workflow/node-processor/condition_node.go deleted file mode 100644 index 3b87ddf99..000000000 --- a/internal/workflow/node-processor/condition_node.go +++ /dev/null @@ -1,52 +0,0 @@ -package nodeprocessor - -import ( - "context" - "errors" - "fmt" - - "github.com/certimate-go/certimate/internal/domain" - "github.com/certimate-go/certimate/internal/domain/expr" -) - -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 { - nodeCfg := n.node.GetConfigForCondition() - if nodeCfg.Expression == nil { - n.logger.Info("without any conditions, enter this branch") - return nil - } - - rs, err := n.evalExpr(ctx, nodeCfg.Expression) - if err != nil { - 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") // TODO: 错误处理 - } else { - n.logger.Info("condition met, enter this branch") - } - - return nil -} - -func (n *conditionNode) evalExpr(ctx context.Context, expression expr.Expr) (*expr.EvalResult, error) { - variables := GetAllNodeOutputs(ctx) - return expression.Eval(variables) -} diff --git a/internal/workflow/node-processor/const.go b/internal/workflow/node-processor/const.go deleted file mode 100644 index 21bdf167c..000000000 --- a/internal/workflow/node-processor/const.go +++ /dev/null @@ -1,7 +0,0 @@ -package nodeprocessor - -const ( - outputKeyForCertificateValidity = "certificate.validity" - outputKeyForCertificateDaysLeft = "certificate.daysLeft" - outputKeyForNodeSkipped = "node.skipped" -) diff --git a/internal/workflow/node-processor/context.go b/internal/workflow/node-processor/context.go deleted file mode 100644 index d600554d9..000000000 --- a/internal/workflow/node-processor/context.go +++ /dev/null @@ -1,103 +0,0 @@ -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 getNodeOutputsContainer(ctx context.Context) *nodeOutputsContainer { - value := ctx.Value(nodeOutputsKey) - if value == nil { - return nil - } - return value.(*nodeOutputsContainer) -} - -// 添加节点输出到上下文 -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() - - // 创建输出的深拷贝 - // TODO: 暂时使用浅拷贝,等后续值类型扩充后修改 - 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 { - container = newNodeOutputsContainer() - } - - 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 GetAllNodeOutputs(ctx context.Context) map[string]map[string]any { - container := getNodeOutputsContainer(ctx) - if container == nil { - container = newNodeOutputsContainer() - } - - container.RLock() - 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)) - for k, v := range output { - nodeCopy[k] = v - } - allOutputs[nodeId] = nodeCopy - } - - return allOutputs -} diff --git a/internal/workflow/node-processor/deploy_node.go b/internal/workflow/node-processor/deploy_node.go deleted file mode 100644 index 973fe1f77..000000000 --- a/internal/workflow/node-processor/deploy_node.go +++ /dev/null @@ -1,129 +0,0 @@ -package nodeprocessor - -import ( - "context" - "fmt" - "log/slog" - "strconv" - "strings" - - "golang.org/x/exp/maps" - - "github.com/certimate-go/certimate/internal/deployer" - "github.com/certimate-go/certimate/internal/domain" - "github.com/certimate-go/certimate/internal/repository" -) - -type deployNode struct { - node *domain.WorkflowNode - *nodeProcessor - *nodeOutputer - - certRepo certificateRepository - outputRepo workflowOutputRepository -} - -func NewDeployNode(node *domain.WorkflowNode) *deployNode { - return &deployNode{ - node: node, - nodeProcessor: newNodeProcessor(node), - nodeOutputer: newNodeOutputer(), - - certRepo: repository.NewCertificateRepository(), - outputRepo: repository.NewWorkflowOutputRepository(), - } -} - -func (n *deployNode) Process(ctx context.Context) error { - nodeCfg := n.node.GetConfigForDeploy() - n.logger.Info("ready to deploy certificate ...", slog.Any("config", nodeCfg)) - - // 查询上次执行结果 - lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id) - if err != nil && !domain.IsRecordNotFoundError(err) { - return err - } - - // 获取前序节点输出证书 - const DELIMITER = "#" - previousNodeOutputCertificateSource := n.node.GetConfigForDeploy().Certificate - 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) - } - certificate, err := n.certRepo.GetByWorkflowNodeId(ctx, previousNodeOutputCertificateSourceSlice[0]) - if err != nil { - n.logger.Warn("invalid certificate source", slog.String("certificate.source", previousNodeOutputCertificateSource)) - return err - } - - // 检测是否可以跳过本次执行 - if lastOutput != nil && certificate.CreatedAt.Before(lastOutput.UpdatedAt) { - if skippable, reason := n.checkCanSkip(ctx, lastOutput); skippable { - n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(true) - n.logger.Info(fmt.Sprintf("skip this deployment, because %s", reason)) - return nil - } else if reason != "" { - n.logger.Info(fmt.Sprintf("re-deploy, because %s", reason)) - } - } - - // 初始化部署器 - deployer, err := deployer.NewWithWorkflowNode(deployer.DeployerWithWorkflowNodeConfig{ - Node: n.node, - Logger: n.logger, - CertificatePEM: certificate.Certificate, - PrivateKeyPEM: certificate.PrivateKey, - }) - if err != nil { - n.logger.Warn("failed to create deployer provider") - return err - } - - // 部署证书 - if err := deployer.Deploy(ctx); err != nil { - n.logger.Warn("failed to deploy certificate") - return err - } - - // 保存执行结果 - output := &domain.WorkflowOutput{ - WorkflowId: getContextWorkflowId(ctx), - RunId: getContextWorkflowRunId(ctx), - NodeId: n.node.Id, - Node: n.node, - Succeeded: true, - } - if _, err := n.outputRepo.Save(ctx, output); err != nil { - n.logger.Warn("failed to save node output") - return err - } - - // 记录中间结果 - n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(false) - - n.logger.Info("deployment completed") - return nil -} - -func (n *deployNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (_skip bool, _reason string) { - if lastOutput != nil && lastOutput.Succeeded { - // 比较和上次部署时的关键配置(即影响证书部署的)参数是否一致 - thisNodeCfg := n.node.GetConfigForDeploy() - lastNodeCfg := lastOutput.Node.GetConfigForDeploy() - - if thisNodeCfg.ProviderAccessId != lastNodeCfg.ProviderAccessId { - return false, "the configuration item 'ProviderAccessId' changed" - } - if !maps.Equal(thisNodeCfg.ProviderConfig, lastNodeCfg.ProviderConfig) { - return false, "the configuration item 'ProviderConfig' changed" - } - - if thisNodeCfg.SkipOnLastSucceeded { - return true, "the certificate has already been deployed" - } - } - - return false, "" -} diff --git a/internal/workflow/node-processor/execute_failure_node.go b/internal/workflow/node-processor/execute_failure_node.go deleted file mode 100644 index 38892241c..000000000 --- a/internal/workflow/node-processor/execute_failure_node.go +++ /dev/null @@ -1,27 +0,0 @@ -package nodeprocessor - -import ( - "context" - - "github.com/certimate-go/certimate/internal/domain" -) - -type executeFailureNode struct { - node *domain.WorkflowNode - *nodeProcessor - *nodeOutputer -} - -func NewExecuteFailureNode(node *domain.WorkflowNode) *executeFailureNode { - return &executeFailureNode{ - node: node, - nodeProcessor: newNodeProcessor(node), - nodeOutputer: newNodeOutputer(), - } -} - -func (n *executeFailureNode) Process(ctx context.Context) error { - // 此类型节点不需要执行任何操作,直接返回 - - return nil -} diff --git a/internal/workflow/node-processor/execute_success_node.go b/internal/workflow/node-processor/execute_success_node.go deleted file mode 100644 index 47a8645e1..000000000 --- a/internal/workflow/node-processor/execute_success_node.go +++ /dev/null @@ -1,27 +0,0 @@ -package nodeprocessor - -import ( - "context" - - "github.com/certimate-go/certimate/internal/domain" -) - -type executeSuccessNode struct { - node *domain.WorkflowNode - *nodeProcessor - *nodeOutputer -} - -func NewExecuteSuccessNode(node *domain.WorkflowNode) *executeSuccessNode { - return &executeSuccessNode{ - node: node, - nodeProcessor: newNodeProcessor(node), - nodeOutputer: newNodeOutputer(), - } -} - -func (n *executeSuccessNode) Process(ctx context.Context) error { - // 此类型节点不需要执行任何操作,直接返回 - - return nil -} diff --git a/internal/workflow/node-processor/monitor_node_test.go b/internal/workflow/node-processor/monitor_node_test.go deleted file mode 100644 index 53afb21c0..000000000 --- a/internal/workflow/node-processor/monitor_node_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package nodeprocessor_test - -import ( - "context" - "log/slog" - "testing" - - "github.com/certimate-go/certimate/internal/domain" - nodeprocessor "github.com/certimate-go/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 deleted file mode 100644 index 090a9f2de..000000000 --- a/internal/workflow/node-processor/notify_node.go +++ /dev/null @@ -1,80 +0,0 @@ -package nodeprocessor - -import ( - "context" - "fmt" - "log/slog" - "strconv" - - "github.com/certimate-go/certimate/internal/domain" - "github.com/certimate-go/certimate/internal/notify" - "github.com/certimate-go/certimate/internal/repository" -) - -type notifyNode struct { - node *domain.WorkflowNode - *nodeProcessor - *nodeOutputer - - settingsRepo settingsRepository -} - -func NewNotifyNode(node *domain.WorkflowNode) *notifyNode { - return ¬ifyNode{ - node: node, - nodeProcessor: newNodeProcessor(node), - nodeOutputer: newNodeOutputer(), - - settingsRepo: repository.NewSettingsRepository(), - } -} - -func (n *notifyNode) Process(ctx context.Context) error { - nodeCfg := n.node.GetConfigForNotify() - n.logger.Info("ready to send notification ...", slog.Any("config", nodeCfg)) - - // 检测是否可以跳过本次执行 - if skippable := n.checkCanSkip(ctx); skippable { - n.logger.Info(fmt.Sprintf("skip this notification, because all the previous nodes have been skipped")) - return nil - } - - // 初始化通知器 - deployer, err := notify.NewWithWorkflowNode(notify.NotifierWithWorkflowNodeConfig{ - Node: n.node, - Logger: n.logger, - Subject: nodeCfg.Subject, - Message: nodeCfg.Message, - }) - if err != nil { - n.logger.Warn("failed to create notifier provider") - return err - } - - // 推送通知 - if err := deployer.Notify(ctx); err != nil { - n.logger.Warn("failed to send notification") - return err - } - - n.logger.Info("notification completed") - return nil -} - -func (n *notifyNode) checkCanSkip(ctx context.Context) (_skip bool) { - thisNodeCfg := n.node.GetConfigForNotify() - if !thisNodeCfg.SkipOnAllPrevSkipped { - return false - } - - prevNodeOutputs := GetAllNodeOutputs(ctx) - for _, nodeOutput := range prevNodeOutputs { - if nodeOutput[outputKeyForNodeSkipped] != nil { - if nodeOutput[outputKeyForNodeSkipped].(string) != strconv.FormatBool(true) { - return false - } - } - } - - return true -} diff --git a/internal/workflow/node-processor/processor.go b/internal/workflow/node-processor/processor.go deleted file mode 100644 index 10e0de9b1..000000000 --- a/internal/workflow/node-processor/processor.go +++ /dev/null @@ -1,104 +0,0 @@ -package nodeprocessor - -import ( - "context" - "fmt" - "io" - "log/slog" - - "github.com/certimate-go/certimate/internal/domain" -) - -type NodeProcessor interface { - GetLogger() *slog.Logger - SetLogger(*slog.Logger) - - Process(ctx context.Context) error - - GetOutputs() map[string]any -} - -type nodeProcessor struct { - logger *slog.Logger -} - -func (n *nodeProcessor) GetLogger() *slog.Logger { - return n.logger -} - -func (n *nodeProcessor) SetLogger(logger *slog.Logger) { - if logger == nil { - panic("logger is nil") - } - - 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) - GetByWorkflowRunIdAndNodeId(ctx context.Context, workflowRunId string, workflowNodeId string) (*domain.Certificate, error) - Save(ctx context.Context, certificate *domain.Certificate) (*domain.Certificate, error) -} - -type workflowOutputRepository interface { - GetByNodeId(ctx context.Context, workflowNodeId string) (*domain.WorkflowOutput, error) - Save(ctx context.Context, workflowOutput *domain.WorkflowOutput) (*domain.WorkflowOutput, error) - SaveWithCertificate(ctx context.Context, workflowOutput *domain.WorkflowOutput, certificate *domain.Certificate) (*domain.WorkflowOutput, error) -} - -type settingsRepository interface { - GetByName(ctx context.Context, name string) (*domain.Settings, error) -} - -func newNodeProcessor(node *domain.WorkflowNode) *nodeProcessor { - return &nodeProcessor{ - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - } -} - -func GetProcessor(node *domain.WorkflowNode) (NodeProcessor, error) { - switch node.Type { - case domain.WorkflowNodeTypeStart: - return NewStartNode(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 - } - - return nil, fmt.Errorf("unsupported node type: %s", string(node.Type)) -} - -func getContextWorkflowId(ctx context.Context) string { - return ctx.Value("workflow_id").(string) -} - -func getContextWorkflowRunId(ctx context.Context) string { - return ctx.Value("workflow_run_id").(string) -} diff --git a/internal/workflow/node-processor/start_node.go b/internal/workflow/node-processor/start_node.go deleted file mode 100644 index b91420036..000000000 --- a/internal/workflow/node-processor/start_node.go +++ /dev/null @@ -1,28 +0,0 @@ -package nodeprocessor - -import ( - "context" - - "github.com/certimate-go/certimate/internal/domain" -) - -type startNode struct { - node *domain.WorkflowNode - *nodeProcessor - *nodeOutputer -} - -func NewStartNode(node *domain.WorkflowNode) *startNode { - return &startNode{ - node: node, - nodeProcessor: newNodeProcessor(node), - nodeOutputer: newNodeOutputer(), - } -} - -func (n *startNode) Process(ctx context.Context) error { - // 此类型节点不需要执行任何操作,直接返回 - n.logger.Info("workflow is started") - - return nil -} diff --git a/internal/workflow/node-processor/upload_node.go b/internal/workflow/node-processor/upload_node.go deleted file mode 100644 index 3112d0806..000000000 --- a/internal/workflow/node-processor/upload_node.go +++ /dev/null @@ -1,107 +0,0 @@ -package nodeprocessor - -import ( - "context" - "fmt" - "log/slog" - "strconv" - "strings" - "time" - - "github.com/certimate-go/certimate/internal/domain" - "github.com/certimate-go/certimate/internal/repository" -) - -type uploadNode struct { - node *domain.WorkflowNode - *nodeProcessor - *nodeOutputer - - certRepo certificateRepository - outputRepo workflowOutputRepository -} - -func NewUploadNode(node *domain.WorkflowNode) *uploadNode { - return &uploadNode{ - node: node, - nodeProcessor: newNodeProcessor(node), - nodeOutputer: newNodeOutputer(), - - certRepo: repository.NewCertificateRepository(), - outputRepo: repository.NewWorkflowOutputRepository(), - } -} - -func (n *uploadNode) Process(ctx context.Context) error { - nodeCfg := n.node.GetConfigForUpload() - n.logger.Info("ready to upload certiticate ...", slog.Any("config", nodeCfg)) - - // 查询上次执行结果 - lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id) - if err != nil && !domain.IsRecordNotFoundError(err) { - return err - } - - // 检测是否可以跳过本次执行 - if skippable, reason := n.checkCanSkip(ctx, lastOutput); skippable { - n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(true) - 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)) - } - - // 生成证书实体 - certificate := &domain.Certificate{ - Source: domain.CertificateSourceTypeUpload, - } - certificate.PopulateFromPEM(nodeCfg.Certificate, nodeCfg.PrivateKey) - - // 保存执行结果 - output := &domain.WorkflowOutput{ - WorkflowId: getContextWorkflowId(ctx), - RunId: getContextWorkflowRunId(ctx), - NodeId: n.node.Id, - Node: n.node, - Succeeded: true, - Outputs: n.node.Outputs, - } - if _, err := n.outputRepo.SaveWithCertificate(ctx, output, certificate); err != nil { - n.logger.Warn("failed to save node output") - return err - } - - // 记录中间结果 - n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(false) - n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(true) - n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(time.Until(certificate.ValidityNotAfter).Hours()/24), 10) - - n.logger.Info("uploading completed") - return nil -} - -func (n *uploadNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (_skip bool, _reason string) { - if lastOutput != nil && lastOutput.Succeeded { - // 比较和上次上传时的关键配置(即影响证书上传的)参数是否一致 - thisNodeCfg := n.node.GetConfigForUpload() - lastNodeCfg := lastOutput.Node.GetConfigForUpload() - - if strings.TrimSpace(thisNodeCfg.Certificate) != strings.TrimSpace(lastNodeCfg.Certificate) { - return false, "the configuration item 'Certificate' changed" - } - if strings.TrimSpace(thisNodeCfg.PrivateKey) != strings.TrimSpace(lastNodeCfg.PrivateKey) { - return false, "the configuration item 'PrivateKey' changed" - } - - lastCertificate, _ := n.certRepo.GetByWorkflowRunIdAndNodeId(ctx, lastOutput.RunId, lastOutput.NodeId) - if lastCertificate != nil { - daysLeft := int(time.Until(lastCertificate.ValidityNotAfter).Hours() / 24) - n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(daysLeft > 0) - n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(daysLeft), 10) - - return true, "the certificate has already been uploaded" - } - } - - return false, "" -} diff --git a/internal/workflow/service.go b/internal/workflow/service.go index 3635fd2dd..9b476ea9c 100644 --- a/internal/workflow/service.go +++ b/internal/workflow/service.go @@ -22,7 +22,7 @@ type workflowRepository interface { type workflowRunRepository interface { GetById(ctx context.Context, id string) (*domain.WorkflowRun, error) - Save(ctx context.Context, workflowRun *domain.WorkflowRun) (*domain.WorkflowRun, error) + SaveWithCascading(ctx context.Context, workflowRun *domain.WorkflowRun) (*domain.WorkflowRun, error) DeleteWhere(ctx context.Context, exprs ...dbx.Expression) (int, error) } @@ -54,7 +54,7 @@ func (s *WorkflowService) InitSchedule(ctx context.Context) error { app.GetScheduler().MustAdd("workflowHistoryRunsCleanup", "0 0 * * *", func() { settings, err := s.settingsRepo.GetByName(ctx, "persistence") if err != nil { - app.GetLogger().Error("failed to get persistence settings", "err", err) + app.GetLogger().Error(fmt.Sprintf("failed to get persistence settings: %w", err)) return } @@ -63,11 +63,11 @@ func (s *WorkflowService) InitSchedule(ctx context.Context) error { ret, err := s.workflowRunRepo.DeleteWhere( context.Background(), dbx.NewExp(fmt.Sprintf("status!='%s'", string(domain.WorkflowRunStatusTypePending))), - dbx.NewExp(fmt.Sprintf("status!='%s'", string(domain.WorkflowRunStatusTypeRunning))), + dbx.NewExp(fmt.Sprintf("status!='%s'", string(domain.WorkflowRunStatusTypeProcessing))), dbx.NewExp(fmt.Sprintf("endedAt 0 { @@ -76,7 +76,7 @@ func (s *WorkflowService) InitSchedule(ctx context.Context) error { } }) - // 工作流 + // 工作流后台任务 { workflows, err := s.workflowRepo.ListEnabledScheduled(ctx) if err != nil { @@ -93,6 +93,7 @@ func (s *WorkflowService) InitSchedule(ctx context.Context) error { }) }) if err != nil { + app.GetLogger().Error(fmt.Sprintf("failed to add workflow #%s to scheduler: %w", workflow.Id, err)) errs = append(errs, err) } @@ -111,8 +112,8 @@ func (s *WorkflowService) StartRun(ctx context.Context, req *dtos.WorkflowStartR return err } - if workflow.LastRunStatus == domain.WorkflowRunStatusTypePending || workflow.LastRunStatus == domain.WorkflowRunStatusTypeRunning { - return errors.New("workflow is already pending or running") + if workflow.LastRunStatus == domain.WorkflowRunStatusTypePending || workflow.LastRunStatus == domain.WorkflowRunStatusTypeProcessing { + return errors.New("workflow is already pending or processing") } run := &domain.WorkflowRun{ @@ -120,18 +121,18 @@ func (s *WorkflowService) StartRun(ctx context.Context, req *dtos.WorkflowStartR Status: domain.WorkflowRunStatusTypePending, Trigger: req.RunTrigger, StartedAt: time.Now(), - Graph: workflow.GraphContent, + Graph: &domain.WorkflowGraphWithResult{WorkflowGraph: *workflow.GraphContent}, } - if resp, err := s.workflowRunRepo.Save(ctx, run); err != nil { + if resp, err := s.workflowRunRepo.SaveWithCascading(ctx, run); err != nil { return err } else { run = resp } s.dispatcher.Dispatch(&dispatcher.WorkflowWorkerData{ - WorkflowId: run.WorkflowId, - WorkflowContent: run.Graph, - RunId: run.Id, + WorkflowId: run.WorkflowId, + WorkflowGraph: &run.Graph.WorkflowGraph, + RunId: run.Id, }) return nil @@ -148,8 +149,8 @@ func (s *WorkflowService) CancelRun(ctx context.Context, req *dtos.WorkflowCance return err } else if workflowRun.WorkflowId != workflow.Id { return errors.New("workflow run not found") - } else if workflowRun.Status != domain.WorkflowRunStatusTypePending && workflowRun.Status != domain.WorkflowRunStatusTypeRunning { - return errors.New("workflow run is not pending or running") + } else if workflowRun.Status != domain.WorkflowRunStatusTypePending && workflowRun.Status != domain.WorkflowRunStatusTypeProcessing { + return errors.New("workflow run is not pending or processing") } s.dispatcher.Cancel(workflowRun.Id) diff --git a/migrations/1755187200_v0.4.0_migrate.go b/migrations/1755187200_v0.4.0_migrate.go index de24b86b3..f62e91ba4 100644 --- a/migrations/1755187200_v0.4.0_migrate.go +++ b/migrations/1755187200_v0.4.0_migrate.go @@ -16,6 +16,7 @@ func init() { tracer.Printf("go ...") // update collection `settings` + // - delete records: 'notifyChannels', 'notifyTemplates' { collection, err := app.FindCollectionByNameOrId("dy6ccjb60spfy6p") if err != nil { @@ -35,11 +36,16 @@ func init() { // update collection `access` // - modify field `config` schema: rename property `defaultReceiver` to `receiver` + // - delete records: 'local' { collection, err := app.FindCollectionByNameOrId("4yzbv8urny5ja1e") if err != nil { return err } else if collection != nil { + if _, err := app.DB().NewQuery("DELETE FROM access WHERE provider = 'local'").Execute(); err != nil { + return err + } + records, err := app.FindAllRecords(collection) if err != nil { return err @@ -621,16 +627,16 @@ func init() { return err } else if collection != nil { if field := collection.Fields.GetByName("level"); field != nil && field.Type() == "text" { - if _, err := app.DB().NewQuery("UPDATE workflow_logs SET level = -4 WHERE level = 'DEBUG'").Execute(); err != nil { + if _, err := app.DB().NewQuery("UPDATE workflow_logs SET level = '-4' WHERE level = 'DEBUG'").Execute(); err != nil { return err } - if _, err := app.DB().NewQuery("UPDATE workflow_logs SET level = 0 WHERE level = 'INFO'").Execute(); err != nil { + if _, err := app.DB().NewQuery("UPDATE workflow_logs SET level = '0' WHERE level = 'INFO'").Execute(); err != nil { return err } - if _, err := app.DB().NewQuery("UPDATE workflow_logs SET level = 4 WHERE level = 'WARN'").Execute(); err != nil { + if _, err := app.DB().NewQuery("UPDATE workflow_logs SET level = '4' WHERE level = 'WARN'").Execute(); err != nil { return err } - if _, err := app.DB().NewQuery("UPDATE workflow_logs SET level = 8 WHERE level = 'ERROR'").Execute(); err != nil { + if _, err := app.DB().NewQuery("UPDATE workflow_logs SET level = '8' WHERE level = 'ERROR'").Execute(); err != nil { return err } @@ -652,6 +658,10 @@ func init() { return err } + if _, err := app.DB().NewQuery("UPDATE workflow_logs SET level = levelTmp").Execute(); err != nil { + return err + } + collection.Fields.RemoveById(field.GetId()) if err := app.Save(collection); err != nil { return err diff --git a/pkg/logging/handler.go b/pkg/logging/handler.go index 519cf18bf..c69c4bbf1 100644 --- a/pkg/logging/handler.go +++ b/pkg/logging/handler.go @@ -34,7 +34,7 @@ func NewHookHandler(opts *HookHandlerOptions) *HookHandler { } if h.options.WriteFunc == nil { - panic("`options.WriteFunc` is nil") + panic("the `options.WriteFunc` is nil") } if h.options.Level == nil { diff --git a/pkg/utils/cert/converter.go b/pkg/utils/cert/converter.go index f6fddcde0..f7b1652aa 100644 --- a/pkg/utils/cert/converter.go +++ b/pkg/utils/cert/converter.go @@ -18,7 +18,7 @@ import ( // - err: 错误。 func ConvertCertificateToPEM(cert *x509.Certificate) (_certPEM string, _err error) { if cert == nil { - return "", errors.New("`cert` is nil") + return "", errors.New("the input certificate is nil") } block := &pem.Block{ @@ -39,7 +39,7 @@ func ConvertCertificateToPEM(cert *x509.Certificate) (_certPEM string, _err erro // - err: 错误。 func ConvertECPrivateKeyToPEM(privkey *ecdsa.PrivateKey) (_privkeyPEM string, _err error) { if privkey == nil { - return "", errors.New("`privkey` is nil") + return "", errors.New("the input private key is nil") } data, _err := x509.MarshalECPrivateKey(privkey) diff --git a/ui/src/components/workflow/designer/forms/StartNodeConfigForm.tsx b/ui/src/components/workflow/designer/forms/StartNodeConfigForm.tsx index 9c458ca8e..404dac55b 100644 --- a/ui/src/components/workflow/designer/forms/StartNodeConfigForm.tsx +++ b/ui/src/components/workflow/designer/forms/StartNodeConfigForm.tsx @@ -49,7 +49,7 @@ const StartNodeConfigForm = ({ node, ...props }: StartNodeConfigFormProps) => { const handleTriggerChange = (value: string) => { if (value === WORKFLOW_TRIGGERS.SCHEDULED) { - formInst.setFieldValue("triggerCron", initialValues?.triggerCron || getInitialValues().triggerCron); + formInst.setFieldValue("triggerCron", initialValues?.triggerCron || "0 0 * * *"); } else { formInst.setFieldValue("triggerCron", void 0); } diff --git a/ui/src/i18n/locales/en/nls.workflow.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json index 367377c92..5646b884d 100644 --- a/ui/src/i18n/locales/en/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/en/nls.workflow.nodes.json @@ -14,8 +14,8 @@ "workflow_node.start.form.trigger_cron.label": "Cron expression", "workflow_node.start.form.trigger_cron.placeholder": "Please enter cron expression", "workflow_node.start.form.trigger_cron.errmsg.invalid": "Please enter a valid cron expression", - "workflow_node.start.form.trigger_cron.tooltip": "Exactly 5 space separated segments. Time zone is based on the server.", - "workflow_node.start.form.trigger_cron.help": "Expected execution time for the last 5 times:", + "workflow_node.start.form.trigger_cron.tooltip": "Exactly 5 space separated segments.", + "workflow_node.start.form.trigger_cron.help": "Expected execution time for the last 5 times (the actual time zone is based on the server):", "workflow_node.start.form.trigger_cron.guide": "If you have multiple workflows, it is recommended to set them to run at different times of the day instead of always running at a specific time. And please 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": "Request certificate", diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index d512f08b8..b098eea67 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -14,8 +14,8 @@ "workflow_node.start.form.trigger_cron.label": "Cron 表达式", "workflow_node.start.form.trigger_cron.placeholder": "请输入 Cron 表达式", "workflow_node.start.form.trigger_cron.errmsg.invalid": "请输入正确的 Cron 表达式", - "workflow_node.start.form.trigger_cron.tooltip": "五段式表达式,支持使用任意值(即 *)、值列表分隔符(即 ,)、值的范围(即 -)、步骤值(即 /)等四种表达式。时区以服务器设置为准。", - "workflow_node.start.form.trigger_cron.help": "预计最近 5 次运行时间:", + "workflow_node.start.form.trigger_cron.tooltip": "五段式表达式。
支持使用任意值(即 *)、值列表分隔符(即 ,)、值的范围(即 -)、步骤值(即 /)等四种表达式。", + "workflow_node.start.form.trigger_cron.help": "预计最近 5 次运行时间(实际时区以服务器设置为准):", "workflow_node.start.form.trigger_cron.guide": "如果你有多个工作流,建议将它们设置为在一天中的多个时间段运行,而非总是在相同的特定时间。也不要总是设置为每日零时,以免遭遇证书颁发机构的流量高峰。

参考链接:
1. Let’s Encrypt 速率限制
2. 为什么我的 Let’s Encrypt (ACME) 客户端启动时间应当随机?", "workflow_node.apply.label": "申请签发证书", From 26d2322a906e0d9ed0f4616f8abf46511cf19d35 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Wed, 20 Aug 2025 22:19:09 +0800 Subject: [PATCH 11/20] feat(ui): show notification when workflow not exists --- ui/src/i18n/locales/zh/nls.access.json | 2 +- ui/src/pages/workflows/WorkflowDetail.tsx | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/ui/src/i18n/locales/zh/nls.access.json b/ui/src/i18n/locales/zh/nls.access.json index 79b54605a..353fcabf5 100644 --- a/ui/src/i18n/locales/zh/nls.access.json +++ b/ui/src/i18n/locales/zh/nls.access.json @@ -39,7 +39,7 @@ "access.form.name.placeholder": "请输入授权名称", "access.form.provider.label": "提供商", "access.form.provider.placeholder": "请选择提供商", - "access.form.provider.help": "提供商分为两种类型:
【DNS 提供商】你的 DNS 托管方,通常等同于域名注册商,用于在申请证书时管理你的域名解析记录。
【主机提供商】你的服务器或云服务的托管方,用于部署签发的证书。", + "access.form.provider.help": "提供商分为两种类型:
【DNS 提供商】你的 DNS 托管方,通常等同于域名注册商,用于在申请证书时管理域名解析记录。
【主机提供商】你的服务器或云服务的托管方,用于部署签发的证书。", "access.form.provider.search.placeholder": "搜索提供商……", "access.form.common_allow_insecure_conns.label": "忽略 SSL/TLS 证书错误", "access.form.common_allow_insecure_conns.switch.on": "允许", diff --git a/ui/src/pages/workflows/WorkflowDetail.tsx b/ui/src/pages/workflows/WorkflowDetail.tsx index bca40db73..5ce0f68a1 100644 --- a/ui/src/pages/workflows/WorkflowDetail.tsx +++ b/ui/src/pages/workflows/WorkflowDetail.tsx @@ -24,7 +24,10 @@ const WorkflowDetail = () => { const { id: workflowId } = useParams(); const { workflow, initialized, ...workflowState } = useWorkflowStore(useZustandShallowSelector(["workflow", "initialized", "init", "destroy", "setEnabled"])); useEffect(() => { - workflowState.init(workflowId!); + Promise.try(() => workflowState.init(workflowId!)).catch((err) => { + console.error(err); + notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) }); + }); return () => { workflowState.destroy(); From 4fb09b3ab100709811a9b255f07157756698d538 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Wed, 20 Aug 2025 22:19:27 +0800 Subject: [PATCH 12/20] fix(ui): browser happy error --- ui/src/utils/browser.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ui/src/utils/browser.ts b/ui/src/utils/browser.ts index 735bb26ae..911e55277 100644 --- a/ui/src/utils/browser.ts +++ b/ui/src/utils/browser.ts @@ -1,3 +1,11 @@ export const isBrowserHappy = () => { - return typeof Promise.withResolvers === "function"; + try { + if (typeof Promise.withResolvers !== "function") return false; + if (typeof CSS.supports !== "function") return false; + if (!CSS.supports("color", "oklch(0 0 0)")) return false; + } catch (_) { + return false; + } + + return true; }; From 1bc92b5b780b1e44dadb8cd28e5f9ff069c9864d Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Thu, 21 Aug 2025 10:24:56 +0800 Subject: [PATCH 13/20] refactor: make workflow node config persistence in outputs --- internal/applicant/applicant.go | 2 +- internal/deployer/deployer.go | 2 +- internal/domain/workflow.go | 164 +++++++++--------- internal/domain/workflow_output.go | 1 + internal/notify/notifier.go | 2 +- internal/repository/workflow_output.go | 7 + internal/workflow/engine/engine.go | 17 +- internal/workflow/engine/executor_bizapply.go | 18 +- .../workflow/engine/executor_bizdeploy.go | 18 +- .../workflow/engine/executor_bizmonitor.go | 2 +- .../workflow/engine/executor_biznotify.go | 4 +- .../workflow/engine/executor_bizupload.go | 18 +- .../workflow/engine/executor_condition.go | 4 +- internal/workflow/engine/executor_trycatch.go | 5 +- migrations/1755187200_v0.4.0_migrate.go | 30 +++- 15 files changed, 149 insertions(+), 145 deletions(-) diff --git a/internal/applicant/applicant.go b/internal/applicant/applicant.go index 49a56d0b6..2240df4f0 100644 --- a/internal/applicant/applicant.go +++ b/internal/applicant/applicant.go @@ -54,7 +54,7 @@ func NewWithWorkflowNode(config ApplicantWithWorkflowNodeConfig) (Applicant, err return nil, fmt.Errorf("the node type is '%s', expected '%s'", string(config.Node.Type), string(domain.WorkflowNodeTypeBizApply)) } - nodeCfg := config.Node.GetConfigForBizApply() + nodeCfg := config.Node.Data.Config.AsBizApply() options := &applicantProviderOptions{ Domains: lo.Filter(strings.Split(nodeCfg.Domains, ";"), func(s string, _ int) bool { return s != "" }), ContactEmail: nodeCfg.ContactEmail, diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index fc1463cbc..cec8e38f0 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -29,7 +29,7 @@ func NewWithWorkflowNode(config DeployerWithWorkflowNodeConfig) (Deployer, error return nil, fmt.Errorf("the node type is '%s', expected '%s'", string(config.Node.Type), string(domain.WorkflowNodeTypeBizDeploy)) } - nodeCfg := config.Node.GetConfigForBizDeploy() + nodeCfg := config.Node.Data.Config.AsBizDeploy() options := &deployerProviderOptions{ Provider: domain.DeploymentProviderType(nodeCfg.Provider), ProviderAccessConfig: make(map[string]any), diff --git a/internal/domain/workflow.go b/internal/domain/workflow.go index 008ba59c7..877c29349 100644 --- a/internal/domain/workflow.go +++ b/internal/domain/workflow.go @@ -95,8 +95,89 @@ const ( ) type WorkflowNodeData struct { - Name string `json:"name"` - Config map[string]any `json:"config"` + Name string `json:"name"` + Config WorkflowNodeConfig `json:"config"` +} + +type WorkflowNodeConfig map[string]any + +func (c WorkflowNodeConfig) AsBizApply() WorkflowNodeConfigForBizApply { + return WorkflowNodeConfigForBizApply{ + Domains: xmaps.GetString(c, "domains"), + ContactEmail: xmaps.GetString(c, "contactEmail"), + ChallengeType: xmaps.GetString(c, "challengeType"), + Provider: xmaps.GetString(c, "provider"), + ProviderAccessId: xmaps.GetString(c, "providerAccessId"), + ProviderConfig: xmaps.GetKVMapAny(c, "providerConfig"), + KeyAlgorithm: xmaps.GetOrDefaultString(c, "keyAlgorithm", string(CertificateKeyAlgorithmTypeRSA2048)), + CAProvider: xmaps.GetString(c, "caProvider"), + CAProviderAccessId: xmaps.GetString(c, "caProviderAccessId"), + CAProviderConfig: xmaps.GetKVMapAny(c, "caProviderConfig"), + ACMEProfile: xmaps.GetString(c, "acmeProfile"), + Nameservers: xmaps.GetString(c, "nameservers"), + DnsPropagationWait: xmaps.GetInt32(c, "dnsPropagationWait"), + DnsPropagationTimeout: xmaps.GetInt32(c, "dnsPropagationTimeout"), + DnsTTL: xmaps.GetInt32(c, "dnsTTL"), + DisableFollowCNAME: xmaps.GetBool(c, "disableFollowCNAME"), + DisableARI: xmaps.GetBool(c, "disableARI"), + SkipBeforeExpiryDays: xmaps.GetInt32(c, "skipBeforeExpiryDays"), + } +} + +func (c WorkflowNodeConfig) AsBizUpload() WorkflowNodeConfigForBizUpload { + return WorkflowNodeConfigForBizUpload{ + Certificate: xmaps.GetString(c, "certificate"), + PrivateKey: xmaps.GetString(c, "privateKey"), + Domains: xmaps.GetString(c, "domains"), + } +} + +func (c WorkflowNodeConfig) AsBizMonitor() WorkflowNodeConfigForBizMonitor { + host := xmaps.GetString(c, "host") + return WorkflowNodeConfigForBizMonitor{ + Host: host, + Port: xmaps.GetOrDefaultInt32(c, "port", 443), + Domain: xmaps.GetOrDefaultString(c, "domain", host), + RequestPath: xmaps.GetString(c, "path"), + } +} + +func (c WorkflowNodeConfig) AsBizDeploy() WorkflowNodeConfigForBizDeploy { + return WorkflowNodeConfigForBizDeploy{ + CertificateOutputNodeId: xmaps.GetString(c, "certificateOutputNodeId"), + Provider: xmaps.GetString(c, "provider"), + ProviderAccessId: xmaps.GetString(c, "providerAccessId"), + ProviderConfig: xmaps.GetKVMapAny(c, "providerConfig"), + SkipOnLastSucceeded: xmaps.GetBool(c, "skipOnLastSucceeded"), + } +} + +func (c WorkflowNodeConfig) AsBizNotify() WorkflowNodeConfigForBizNotify { + return WorkflowNodeConfigForBizNotify{ + Provider: xmaps.GetString(c, "provider"), + ProviderAccessId: xmaps.GetString(c, "providerAccessId"), + ProviderConfig: xmaps.GetKVMapAny(c, "providerConfig"), + Subject: xmaps.GetString(c, "subject"), + Message: xmaps.GetString(c, "message"), + SkipOnAllPrevSkipped: xmaps.GetBool(c, "skipOnAllPrevSkipped"), + } +} + +func (c WorkflowNodeConfig) AsBranchBlock() WorkflowNodeConfigForBranchBlock { + expression := c["expression"] + if expression == nil { + return WorkflowNodeConfigForBranchBlock{} + } + + exprRaw, _ := json.Marshal(expression) + expr, err := expr.UnmarshalExpr([]byte(exprRaw)) + if err != nil { + return WorkflowNodeConfigForBranchBlock{} + } + + return WorkflowNodeConfigForBranchBlock{ + Expression: expr, + } } type WorkflowNodeConfigForBizApply struct { @@ -153,82 +234,3 @@ type WorkflowNodeConfigForBizNotify struct { type WorkflowNodeConfigForBranchBlock struct { Expression expr.Expr `json:"expression"` // 条件表达式 } - -func (n *WorkflowNode) GetConfigForBizApply() WorkflowNodeConfigForBizApply { - return WorkflowNodeConfigForBizApply{ - Domains: xmaps.GetString(n.Data.Config, "domains"), - ContactEmail: xmaps.GetString(n.Data.Config, "contactEmail"), - ChallengeType: xmaps.GetString(n.Data.Config, "challengeType"), - Provider: xmaps.GetString(n.Data.Config, "provider"), - ProviderAccessId: xmaps.GetString(n.Data.Config, "providerAccessId"), - ProviderConfig: xmaps.GetKVMapAny(n.Data.Config, "providerConfig"), - KeyAlgorithm: xmaps.GetOrDefaultString(n.Data.Config, "keyAlgorithm", string(CertificateKeyAlgorithmTypeRSA2048)), - CAProvider: xmaps.GetString(n.Data.Config, "caProvider"), - CAProviderAccessId: xmaps.GetString(n.Data.Config, "caProviderAccessId"), - CAProviderConfig: xmaps.GetKVMapAny(n.Data.Config, "caProviderConfig"), - ACMEProfile: xmaps.GetString(n.Data.Config, "acmeProfile"), - Nameservers: xmaps.GetString(n.Data.Config, "nameservers"), - DnsPropagationWait: xmaps.GetInt32(n.Data.Config, "dnsPropagationWait"), - DnsPropagationTimeout: xmaps.GetInt32(n.Data.Config, "dnsPropagationTimeout"), - DnsTTL: xmaps.GetInt32(n.Data.Config, "dnsTTL"), - DisableFollowCNAME: xmaps.GetBool(n.Data.Config, "disableFollowCNAME"), - DisableARI: xmaps.GetBool(n.Data.Config, "disableARI"), - SkipBeforeExpiryDays: xmaps.GetInt32(n.Data.Config, "skipBeforeExpiryDays"), - } -} - -func (n *WorkflowNode) GetConfigForBizUpload() WorkflowNodeConfigForBizUpload { - return WorkflowNodeConfigForBizUpload{ - Certificate: xmaps.GetString(n.Data.Config, "certificate"), - PrivateKey: xmaps.GetString(n.Data.Config, "privateKey"), - Domains: xmaps.GetString(n.Data.Config, "domains"), - } -} - -func (n *WorkflowNode) GetConfigForBizMonitor() WorkflowNodeConfigForBizMonitor { - host := xmaps.GetString(n.Data.Config, "host") - return WorkflowNodeConfigForBizMonitor{ - Host: host, - Port: xmaps.GetOrDefaultInt32(n.Data.Config, "port", 443), - Domain: xmaps.GetOrDefaultString(n.Data.Config, "domain", host), - RequestPath: xmaps.GetString(n.Data.Config, "path"), - } -} - -func (n *WorkflowNode) GetConfigForBizDeploy() WorkflowNodeConfigForBizDeploy { - return WorkflowNodeConfigForBizDeploy{ - CertificateOutputNodeId: xmaps.GetString(n.Data.Config, "certificateOutputNodeId"), - Provider: xmaps.GetString(n.Data.Config, "provider"), - ProviderAccessId: xmaps.GetString(n.Data.Config, "providerAccessId"), - ProviderConfig: xmaps.GetKVMapAny(n.Data.Config, "providerConfig"), - SkipOnLastSucceeded: xmaps.GetBool(n.Data.Config, "skipOnLastSucceeded"), - } -} - -func (n *WorkflowNode) GetConfigForBizNotify() WorkflowNodeConfigForBizNotify { - return WorkflowNodeConfigForBizNotify{ - Provider: xmaps.GetString(n.Data.Config, "provider"), - ProviderAccessId: xmaps.GetString(n.Data.Config, "providerAccessId"), - ProviderConfig: xmaps.GetKVMapAny(n.Data.Config, "providerConfig"), - Subject: xmaps.GetString(n.Data.Config, "subject"), - Message: xmaps.GetString(n.Data.Config, "message"), - SkipOnAllPrevSkipped: xmaps.GetBool(n.Data.Config, "skipOnAllPrevSkipped"), - } -} - -func (n *WorkflowNode) GetConfigForBranchBlock() WorkflowNodeConfigForBranchBlock { - expression := n.Data.Config["expression"] - if expression == nil { - return WorkflowNodeConfigForBranchBlock{} - } - - exprRaw, _ := json.Marshal(expression) - expr, err := expr.UnmarshalExpr([]byte(exprRaw)) - if err != nil { - return WorkflowNodeConfigForBranchBlock{} - } - - return WorkflowNodeConfigForBranchBlock{ - Expression: expr, - } -} diff --git a/internal/domain/workflow_output.go b/internal/domain/workflow_output.go index 2108f73b6..28933832e 100644 --- a/internal/domain/workflow_output.go +++ b/internal/domain/workflow_output.go @@ -7,6 +7,7 @@ type WorkflowOutput struct { WorkflowId string `json:"workflowId" db:"workflowRef"` RunId string `json:"runId" db:"runRef"` NodeId string `json:"nodeId" db:"nodeId"` + NodeConfig WorkflowNodeConfig `json:"nodeConfig" db:"nodeConfig"` Outputs []*WorkflowOutputEntry `json:"outputs" db:"outputs"` Succeeded bool `json:"succeeded" db:"succeeded"` } diff --git a/internal/notify/notifier.go b/internal/notify/notifier.go index 533432d6f..8220ac9c0 100644 --- a/internal/notify/notifier.go +++ b/internal/notify/notifier.go @@ -29,7 +29,7 @@ func NewWithWorkflowNode(config NotifierWithWorkflowNodeConfig) (Notifier, error return nil, fmt.Errorf("the node type is '%s', expected '%s'", string(config.Node.Type), string(domain.WorkflowNodeTypeBizNotify)) } - nodeCfg := config.Node.GetConfigForBizNotify() + nodeCfg := config.Node.Data.Config.AsBizNotify() options := ¬ifierProviderOptions{ Provider: domain.NotificationProviderType(nodeCfg.Provider), ProviderAccessConfig: make(map[string]any), diff --git a/internal/repository/workflow_output.go b/internal/repository/workflow_output.go index 3330bf49b..f1b4b0f24 100644 --- a/internal/repository/workflow_output.go +++ b/internal/repository/workflow_output.go @@ -107,6 +107,11 @@ func (r *WorkflowOutputRepository) castRecordToModel(record *core.Record) (*doma return nil, fmt.Errorf("the record is nil") } + nodeConfig := make(domain.WorkflowNodeConfig) + if err := record.UnmarshalJSONField("nodeConfig", &nodeConfig); err != nil { + return nil, fmt.Errorf("field 'nodeConfig' is malformed") + } + outputs := make([]*domain.WorkflowOutputEntry, 0) if err := record.UnmarshalJSONField("outputs", &outputs); err != nil { return nil, fmt.Errorf("field 'outputs' is malformed") @@ -121,6 +126,7 @@ func (r *WorkflowOutputRepository) castRecordToModel(record *core.Record) (*doma WorkflowId: record.GetString("workflowRef"), RunId: record.GetString("runRef"), NodeId: record.GetString("nodeId"), + NodeConfig: nodeConfig, Outputs: outputs, Succeeded: record.GetBool("succeeded"), } @@ -145,6 +151,7 @@ func (r *WorkflowOutputRepository) saveRecord(workflowOutput *domain.WorkflowOut record.Set("workflowRef", workflowOutput.WorkflowId) record.Set("runRef", workflowOutput.RunId) record.Set("nodeId", workflowOutput.NodeId) + record.Set("nodeConfig", workflowOutput.NodeConfig) record.Set("outputs", workflowOutput.Outputs) record.Set("succeeded", workflowOutput.Succeeded) if err := app.GetApp().Save(record); err != nil { diff --git a/internal/workflow/engine/engine.go b/internal/workflow/engine/engine.go index 660e1ff91..ab3bbad16 100644 --- a/internal/workflow/engine/engine.go +++ b/internal/workflow/engine/engine.go @@ -24,6 +24,8 @@ type WorkflowEngine interface { } type workflowEngine struct { + logger *slog.Logger + executorRegistry map[NodeType]NodeExecutor hooksMtx sync.RWMutex @@ -184,7 +186,7 @@ func (we *workflowEngine) fireOnStartHooks(ctx context.Context) { defer we.hooksMtx.RUnlock() for _, cb := range we.onStartHooks { if cbErr := cb(ctx); cbErr != nil { - app.GetLogger().Error("workflow engine: error in onStart hook", slog.Any("error", cbErr)) + we.logger.Error("workflow engine: error in onStart hook", slog.Any("error", cbErr)) } } } @@ -194,7 +196,7 @@ func (we *workflowEngine) fireOnEndHooks(ctx context.Context) { defer we.hooksMtx.RUnlock() for _, cb := range we.onEndHooks { if cbErr := cb(ctx); cbErr != nil { - app.GetLogger().Error("workflow engine: error in onEnd hook", slog.Any("error", cbErr)) + we.logger.Error("workflow engine: error in onEnd hook", slog.Any("error", cbErr)) } } } @@ -204,7 +206,7 @@ func (we *workflowEngine) fireOnErrorHooks(ctx context.Context, err error) { defer we.hooksMtx.RUnlock() for _, cb := range we.onErrorHooks { if cbErr := cb(ctx, err); cbErr != nil { - app.GetLogger().Error("workflow engine: error in onError hook", slog.Any("error", cbErr)) + we.logger.Error("workflow engine: error in onError hook", slog.Any("error", cbErr)) } } } @@ -214,7 +216,7 @@ func (we *workflowEngine) fireOnNodeStartHooks(ctx context.Context, node *Node) defer we.hooksMtx.RUnlock() for _, cb := range we.onNodeStartHooks { if cbErr := cb(ctx, node); cbErr != nil { - app.GetLogger().Error("workflow engine: error in onNodeStart hook", slog.Any("error", cbErr)) + we.logger.Error("workflow engine: error in onNodeStart hook", slog.Any("error", cbErr)) } } } @@ -224,7 +226,7 @@ func (we *workflowEngine) fireOnNodeEndHooks(ctx context.Context, node *Node, re defer we.hooksMtx.RUnlock() for _, cb := range we.onNodeEndHooks { if cbErr := cb(ctx, node, result); cbErr != nil { - app.GetLogger().Error("workflow engine: error in onNodeEnd hook", slog.Any("error", cbErr)) + we.logger.Error("workflow engine: error in onNodeEnd hook", slog.Any("error", cbErr)) } } } @@ -234,7 +236,7 @@ func (we *workflowEngine) fireOnNodeErrorHooks(ctx context.Context, node *Node, defer we.hooksMtx.RUnlock() for _, cb := range we.onNodeErrorHooks { if cbErr := cb(ctx, node, err); cbErr != nil { - app.GetLogger().Error("workflow engine: error in onNodeError hook", slog.Any("error", cbErr)) + we.logger.Error("workflow engine: error in onNodeError hook", slog.Any("error", cbErr)) } } } @@ -244,7 +246,7 @@ func (we *workflowEngine) fireOnNodeLoggingHooks(ctx context.Context, node *Node defer we.hooksMtx.RUnlock() for _, cb := range we.onNodeLoggingHooks { if cbErr := cb(ctx, node, log); cbErr != nil { - app.GetLogger().Error("workflow engine: error in onNodeLogging hook", slog.Any("error", cbErr)) + we.logger.Error("workflow engine: error in onNodeLogging hook", slog.Any("error", cbErr)) } } } @@ -252,6 +254,7 @@ func (we *workflowEngine) fireOnNodeLoggingHooks(ctx context.Context, node *Node func NewWorkflowEngine() WorkflowEngine { engine := &workflowEngine{ executorRegistry: make(map[NodeType]NodeExecutor), + logger: app.GetLogger(), } engine.executorRegistry[NodeTypeStart] = newStartNodeExecutor() engine.executorRegistry[NodeTypeEnd] = newEndNodeExecutor() diff --git a/internal/workflow/engine/executor_bizapply.go b/internal/workflow/engine/executor_bizapply.go index 27ecf433d..4e8010e85 100644 --- a/internal/workflow/engine/executor_bizapply.go +++ b/internal/workflow/engine/executor_bizapply.go @@ -16,14 +16,13 @@ type bizApplyNodeExecutor struct { nodeExecutor certificateRepo certificateRepository - wfrunRepo workflowRunRepository wfoutputRepo workflowOutputRepository } func (ne *bizApplyNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { execRes := &NodeExecutionResult{} - nodeCfg := execCtx.Node.GetConfigForBizApply() + nodeCfg := execCtx.Node.Data.Config.AsBizApply() ne.logger.Info("ready to request certificate ...", slog.Any("config", nodeCfg)) // 查询上次执行结果 @@ -87,6 +86,7 @@ func (ne *bizApplyNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExe WorkflowId: execCtx.WorkflowId, RunId: execCtx.RunId, NodeId: execCtx.Node.Id, + NodeConfig: execCtx.Node.Data.Config, Succeeded: true, Outputs: []*domain.WorkflowOutputEntry{ { @@ -136,20 +136,11 @@ func (ne *bizApplyNodeExecutor) getLastOutputArtifacts(execCtx *NodeExecutionCon } func (ne *bizApplyNodeExecutor) checkCanSkip(execCtx *NodeExecutionContext, lastOutput *domain.WorkflowOutput, lastCertificate *domain.Certificate) (_skip bool, _reason string) { - thisNodeCfg := execCtx.Node.GetConfigForBizApply() + thisNodeCfg := execCtx.Node.Data.Config.AsBizApply() if lastOutput != nil && lastOutput.Succeeded { - lastRun, err := ne.wfrunRepo.GetById(execCtx.ctx, lastOutput.RunId) - if err != nil { - return true, "failed to get last run" - } - lastNode, lastNodeExists := lastRun.Graph.GetNodeById(lastOutput.NodeId) - if !lastNodeExists { - return true, "failed to get last run node" - } - // 比较和上次申请时的关键配置(即影响证书签发的)参数是否一致 - lastNodeCfg := lastNode.GetConfigForBizApply() + lastNodeCfg := lastOutput.NodeConfig.AsBizApply() if thisNodeCfg.Domains != lastNodeCfg.Domains { return false, "the configuration item 'Domains' changed" @@ -201,7 +192,6 @@ func newBizApplyNodeExecutor() NodeExecutor { return &bizApplyNodeExecutor{ nodeExecutor: nodeExecutor{logger: slog.Default()}, certificateRepo: repository.NewCertificateRepository(), - wfrunRepo: repository.NewWorkflowRunRepository(), wfoutputRepo: repository.NewWorkflowOutputRepository(), } } diff --git a/internal/workflow/engine/executor_bizdeploy.go b/internal/workflow/engine/executor_bizdeploy.go index 30274e239..048e9a34d 100644 --- a/internal/workflow/engine/executor_bizdeploy.go +++ b/internal/workflow/engine/executor_bizdeploy.go @@ -14,14 +14,13 @@ type bizDeployNodeExecutor struct { nodeExecutor certificateRepo certificateRepository - wfrunRepo workflowRunRepository wfoutputRepo workflowOutputRepository } func (ne *bizDeployNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { execRes := &NodeExecutionResult{} - nodeCfg := execCtx.Node.GetConfigForBizDeploy() + nodeCfg := execCtx.Node.Data.Config.AsBizDeploy() ne.logger.Info("ready to deploy certificate ...", slog.Any("config", nodeCfg)) // 查询上次执行结果 @@ -74,6 +73,7 @@ func (ne *bizDeployNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx WorkflowId: execCtx.WorkflowId, RunId: execCtx.RunId, NodeId: execCtx.Node.Id, + NodeConfig: execCtx.Node.Data.Config, Succeeded: true, } if _, err := ne.wfoutputRepo.Save(execCtx.ctx, output); err != nil { @@ -98,20 +98,11 @@ func (ne *bizDeployNodeExecutor) getLastOutputArtifacts(execCtx *NodeExecutionCo } func (ne *bizDeployNodeExecutor) checkCanSkip(execCtx *NodeExecutionContext, lastOutput *domain.WorkflowOutput) (_skip bool, _reason string) { - thisNodeCfg := execCtx.Node.GetConfigForBizDeploy() + thisNodeCfg := execCtx.Node.Data.Config.AsBizDeploy() if lastOutput != nil && lastOutput.Succeeded { - lastRun, err := ne.wfrunRepo.GetById(execCtx.ctx, lastOutput.RunId) - if err != nil { - return true, "failed to get last run" - } - lastNode, lastNodeExists := lastRun.Graph.GetNodeById(lastOutput.NodeId) - if !lastNodeExists { - return true, "failed to get last run node" - } - // 比较和上次部署时的关键配置(即影响证书部署的)参数是否一致 - lastNodeCfg := lastNode.GetConfigForBizDeploy() + lastNodeCfg := lastOutput.NodeConfig.AsBizDeploy() if thisNodeCfg.ProviderAccessId != lastNodeCfg.ProviderAccessId { return false, "the configuration item 'ProviderAccessId' changed" @@ -132,7 +123,6 @@ func newBizDeployNodeExecutor() NodeExecutor { return &bizDeployNodeExecutor{ nodeExecutor: nodeExecutor{logger: slog.Default()}, certificateRepo: repository.NewCertificateRepository(), - wfrunRepo: repository.NewWorkflowRunRepository(), wfoutputRepo: repository.NewWorkflowOutputRepository(), } } diff --git a/internal/workflow/engine/executor_bizmonitor.go b/internal/workflow/engine/executor_bizmonitor.go index 78de2d954..2e96aa08c 100644 --- a/internal/workflow/engine/executor_bizmonitor.go +++ b/internal/workflow/engine/executor_bizmonitor.go @@ -25,7 +25,7 @@ type bizMonitorNodeExecutor struct { func (ne *bizMonitorNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { execRes := &NodeExecutionResult{} - nodeCfg := execCtx.Node.GetConfigForBizMonitor() + nodeCfg := execCtx.Node.Data.Config.AsBizMonitor() ne.logger.Info("ready to monitor certificate ...", slog.Any("config", nodeCfg)) targetAddr := net.JoinHostPort(nodeCfg.Host, strconv.Itoa(int(nodeCfg.Port))) diff --git a/internal/workflow/engine/executor_biznotify.go b/internal/workflow/engine/executor_biznotify.go index 194831f23..af629f30b 100644 --- a/internal/workflow/engine/executor_biznotify.go +++ b/internal/workflow/engine/executor_biznotify.go @@ -17,7 +17,7 @@ type bizNotifyNodeExecutor struct { func (ne *bizNotifyNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { execRes := &NodeExecutionResult{} - nodeCfg := execCtx.Node.GetConfigForBizNotify() + nodeCfg := execCtx.Node.Data.Config.AsBizNotify() ne.logger.Info("ready to send notification ...", slog.Any("config", nodeCfg)) // 检测是否可以跳过本次执行 @@ -49,7 +49,7 @@ func (ne *bizNotifyNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx } func (ne *bizNotifyNodeExecutor) checkCanSkip(execCtx *NodeExecutionContext) (_skip bool) { - thisNodeCfg := execCtx.Node.GetConfigForBizNotify() + thisNodeCfg := execCtx.Node.Data.Config.AsBizNotify() if !thisNodeCfg.SkipOnAllPrevSkipped { return false } diff --git a/internal/workflow/engine/executor_bizupload.go b/internal/workflow/engine/executor_bizupload.go index d6def222d..e3afdc77f 100644 --- a/internal/workflow/engine/executor_bizupload.go +++ b/internal/workflow/engine/executor_bizupload.go @@ -14,14 +14,13 @@ type bizUploadNodeExecutor struct { nodeExecutor certificateRepo certificateRepository - wfrunRepo workflowRunRepository wfoutputRepo workflowOutputRepository } func (ne *bizUploadNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { execRes := &NodeExecutionResult{} - nodeCfg := execCtx.Node.GetConfigForBizUpload() + nodeCfg := execCtx.Node.Data.Config.AsBizUpload() ne.logger.Info("ready to upload certiticate ...", slog.Any("config", nodeCfg)) // 查询上次执行结果 @@ -54,6 +53,7 @@ func (ne *bizUploadNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx WorkflowId: execCtx.WorkflowId, RunId: execCtx.RunId, NodeId: execCtx.Node.Id, + NodeConfig: execCtx.Node.Data.Config, Succeeded: true, Outputs: []*domain.WorkflowOutputEntry{ { @@ -97,20 +97,11 @@ func (ne *bizUploadNodeExecutor) getLastOutputArtifacts(execCtx *NodeExecutionCo } func (ne *bizUploadNodeExecutor) checkCanSkip(execCtx *NodeExecutionContext, lastOutput *domain.WorkflowOutput, lastCertificate *domain.Certificate) (_skip bool, _reason string) { - thisNodeCfg := execCtx.Node.GetConfigForBizUpload() + thisNodeCfg := execCtx.Node.Data.Config.AsBizUpload() if lastOutput != nil && lastOutput.Succeeded { - lastRun, err := ne.wfrunRepo.GetById(execCtx.ctx, lastOutput.RunId) - if err != nil { - return true, "failed to get last run" - } - lastNode, lastNodeExists := lastRun.Graph.GetNodeById(lastOutput.NodeId) - if !lastNodeExists { - return true, "failed to get last run node" - } - // 比较和上次上传时的关键配置(即影响证书上传的)参数是否一致 - lastNodeCfg := lastNode.GetConfigForBizUpload() + lastNodeCfg := lastOutput.NodeConfig.AsBizUpload() if strings.TrimSpace(thisNodeCfg.Certificate) != strings.TrimSpace(lastNodeCfg.Certificate) { return false, "the configuration item 'Certificate' changed" @@ -135,7 +126,6 @@ func newBizUploadNodeExecutor() NodeExecutor { return &bizUploadNodeExecutor{ nodeExecutor: nodeExecutor{logger: slog.Default()}, certificateRepo: repository.NewCertificateRepository(), - wfrunRepo: repository.NewWorkflowRunRepository(), wfoutputRepo: repository.NewWorkflowOutputRepository(), } } diff --git a/internal/workflow/engine/executor_condition.go b/internal/workflow/engine/executor_condition.go index adcb3e7d4..f03de4c92 100644 --- a/internal/workflow/engine/executor_condition.go +++ b/internal/workflow/engine/executor_condition.go @@ -42,7 +42,7 @@ func (ne *conditionNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx } if len(errs) > 0 { - return execRes, errors.Join(errs...) + return execRes, fmt.Errorf("error occurred when executing child nodes: %w", errors.Join(errs...)) } return execRes, nil @@ -61,7 +61,7 @@ type branchBlockNodeExecutor struct { func (ne *branchBlockNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { execRes := &NodeExecutionResult{} - nodeCfg := execCtx.Node.GetConfigForBranchBlock() + nodeCfg := execCtx.Node.Data.Config.AsBranchBlock() if nodeCfg.Expression == nil { ne.logger.Info("enter this branch without any conditions") } else { diff --git a/internal/workflow/engine/executor_trycatch.go b/internal/workflow/engine/executor_trycatch.go index f562981eb..be0753b6b 100644 --- a/internal/workflow/engine/executor_trycatch.go +++ b/internal/workflow/engine/executor_trycatch.go @@ -2,6 +2,7 @@ import ( "errors" + "fmt" "log/slog" "github.com/samber/lo" @@ -59,10 +60,10 @@ func (ne *tryCatchNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExe } if len(catchErrs) > 0 { - return execRes, errors.Join(append(tryErrs, catchErrs...)...) + return execRes, fmt.Errorf("error occurred when executing child nodes: %w", errors.Join(append(tryErrs, catchErrs...)...)) } - return execRes, errors.Join(tryErrs...) + return execRes, fmt.Errorf("error occurred when executing child nodes: %w", errors.Join(tryErrs...)) } return execRes, nil diff --git a/migrations/1755187200_v0.4.0_migrate.go b/migrations/1755187200_v0.4.0_migrate.go index f62e91ba4..b87ebdfbc 100644 --- a/migrations/1755187200_v0.4.0_migrate.go +++ b/migrations/1755187200_v0.4.0_migrate.go @@ -556,7 +556,7 @@ func init() { // update collection `workflow_output` // - rename field `workflowId` to `workflowRef` // - rename field `runId` to `runRef` - // - delete field `node` + // - rename field `node` to `nodeConfig` { collection, err := app.FindCollectionByNameOrId("bqnxb95f2cooowp") if err != nil { @@ -604,8 +604,17 @@ func init() { return err } - if field := collection.Fields.GetByName("node"); field != nil { - collection.Fields.RemoveById(field.GetId()) + if err := collection.Fields.AddMarshaledJSONAt(4, []byte(`{ + "hidden": false, + "id": "json2239752261", + "maxSize": 5000000, + "name": "nodeConfig", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }`)); err != nil { + return err } if err := app.Save(collection); err != nil { @@ -1049,6 +1058,7 @@ func init() { } // update collection `workflow_output` + // - migrate field `nodeConfig` // - migrate field `outputs` { collection, err := app.FindCollectionByNameOrId("bqnxb95f2cooowp") @@ -1062,6 +1072,18 @@ func init() { for _, record := range records { changed := false + nodeConfig := make(map[string]any) + if err := record.UnmarshalJSONField("nodeConfig", &nodeConfig); err == nil { + if _, ok := nodeConfig["id"]; ok { + if _, ok := nodeConfig["type"]; ok { + if _, ok := nodeConfig["config"]; ok { + record.Set("nodeConfig", nodeConfig["config"]) + changed = true + } + } + } + } + outputs := make([]map[string]any, 0) if err := record.UnmarshalJSONField("outputs", &outputs); err == nil { for i, output := range outputs { @@ -1077,8 +1099,6 @@ func init() { record.Set("outputs", outputs) changed = true } - } else { - println(err.Error()) } if changed { From dc8d7add02e63188e8a10f7e3fef12c8938e8509 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Thu, 21 Aug 2025 11:21:48 +0800 Subject: [PATCH 14/20] fix(migration): missing workflow logs level of history data --- ...55187200_v0.4.0_migrate.go => 1755187200_cm0.4.0_migrate.go} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename migrations/{1755187200_v0.4.0_migrate.go => 1755187200_cm0.4.0_migrate.go} (99%) diff --git a/migrations/1755187200_v0.4.0_migrate.go b/migrations/1755187200_cm0.4.0_migrate.go similarity index 99% rename from migrations/1755187200_v0.4.0_migrate.go rename to migrations/1755187200_cm0.4.0_migrate.go index b87ebdfbc..483df9628 100644 --- a/migrations/1755187200_v0.4.0_migrate.go +++ b/migrations/1755187200_cm0.4.0_migrate.go @@ -667,7 +667,7 @@ func init() { return err } - if _, err := app.DB().NewQuery("UPDATE workflow_logs SET level = levelTmp").Execute(); err != nil { + if _, err := app.DB().NewQuery("UPDATE workflow_logs SET levelTmp = level").Execute(); err != nil { return err } From d3b9c8ff5539f9f4810e665e73c2afde68e04e25 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Thu, 21 Aug 2025 12:21:52 +0800 Subject: [PATCH 15/20] refactor: clean code --- internal/workflow/engine/context.go | 139 +----------------- internal/workflow/engine/engine.go | 4 +- internal/workflow/engine/executor.go | 24 +-- internal/workflow/engine/executor_bizapply.go | 8 +- .../workflow/engine/executor_bizdeploy.go | 4 +- .../workflow/engine/executor_bizmonitor.go | 8 +- .../workflow/engine/executor_biznotify.go | 2 +- .../workflow/engine/executor_bizupload.go | 8 +- .../workflow/engine/executor_condition.go | 2 +- internal/workflow/engine/models.go | 8 - internal/workflow/engine/state.go | 139 ++++++++++++++++++ 11 files changed, 175 insertions(+), 171 deletions(-) create mode 100644 internal/workflow/engine/state.go diff --git a/internal/workflow/engine/context.go b/internal/workflow/engine/context.go index 36b3ac875..06797d621 100644 --- a/internal/workflow/engine/context.go +++ b/internal/workflow/engine/context.go @@ -2,8 +2,6 @@ import ( "context" - "slices" - "sync" ) type WorkflowContext struct { @@ -12,8 +10,8 @@ type WorkflowContext struct { RunGraph *Graph engine WorkflowEngine - variables WorkflowIOsManager - inputs WorkflowIOsManager + variables StateManager + inputs StateManager ctx context.Context } @@ -30,13 +28,13 @@ func (c *WorkflowContext) SetEngine(engine WorkflowEngine) *WorkflowContext { return c } -func (c *WorkflowContext) SetVariablesManager(variables WorkflowIOsManager) *WorkflowContext { - c.variables = variables +func (c *WorkflowContext) SetVariablesManager(manager StateManager) *WorkflowContext { + c.variables = manager return c } -func (c *WorkflowContext) SetInputsManager(inputs WorkflowIOsManager) *WorkflowContext { - c.inputs = inputs +func (c *WorkflowContext) SetInputsManager(manager StateManager) *WorkflowContext { + c.inputs = manager return c } @@ -58,128 +56,3 @@ func (c *WorkflowContext) Clone() *WorkflowContext { ctx: c.ctx, } } - -type WorkflowIOsManager interface { - All() []NodeIOEntry - Erase() - - Add(entry NodeIOEntry) - Get(name string) (*NodeIOEntry, bool) - GetScoped(scope string, name string) (*NodeIOEntry, bool) - Take(name string) (*NodeIOEntry, bool) - TakeScoped(scope string, name string) (*NodeIOEntry, bool) - Remove(name string) bool - RemoveScoped(scope string, name string) bool -} - -type workflowIOsManager struct { - entriesMtx sync.RWMutex - entries []NodeIOEntry -} - -var _ WorkflowIOsManager = (*workflowIOsManager)(nil) - -func (m *workflowIOsManager) All() []NodeIOEntry { - m.entriesMtx.RLock() - defer m.entriesMtx.RUnlock() - - if m.entries == nil { - return make([]NodeIOEntry, 0) - } - - return slices.Clone(m.entries) -} - -func (m *workflowIOsManager) Erase() { - m.entriesMtx.Lock() - defer m.entriesMtx.Unlock() - - m.entries = make([]NodeIOEntry, 0) -} - -func (m *workflowIOsManager) Add(entry NodeIOEntry) { - m.entriesMtx.Lock() - defer m.entriesMtx.Unlock() - - if m.entries == nil { - m.entries = make([]NodeIOEntry, 0) - } - - for i, item := range m.entries { - if item.Scope == entry.Scope && item.Key == entry.Key { - m.entries[i] = entry - return - } - } - m.entries = append(m.entries, entry) -} - -func (m *workflowIOsManager) Get(name string) (*NodeIOEntry, bool) { - return m.GetScoped("", name) -} - -func (m *workflowIOsManager) GetScoped(scope string, name string) (*NodeIOEntry, bool) { - m.entriesMtx.RLock() - defer m.entriesMtx.RUnlock() - - if m.entries == nil { - return nil, false - } - - for _, item := range m.entries { - if item.Scope == scope && item.Key == name { - return &item, true - } - } - return nil, false -} - -func (m *workflowIOsManager) Take(name string) (*NodeIOEntry, bool) { - return m.TakeScoped("", name) -} - -func (m *workflowIOsManager) TakeScoped(scope, name string) (*NodeIOEntry, bool) { - m.entriesMtx.Lock() - defer m.entriesMtx.Unlock() - - if m.entries == nil { - return nil, false - } - - for i, item := range m.entries { - if item.Scope == scope && item.Key == name { - m.entries = slices.Delete(m.entries, i, i+1) - return &item, true - } - } - return nil, false -} - -func (m *workflowIOsManager) Remove(name string) bool { - return m.RemoveScoped("", name) -} - -func (m *workflowIOsManager) RemoveScoped(scope string, name string) bool { - _, ok := m.TakeScoped(scope, name) - return ok -} - -func newWorkflowIOsManager() WorkflowIOsManager { - return &workflowIOsManager{ - entries: make([]NodeIOEntry, 0), - } -} - -const ( - wfIOTypeCertificate = "certificate" -) - -const ( - wfIOKeyCertificate = "certificate" -) - -const ( - wfVariableKeyCertificateValidity = "certificate.validity" // ValueType: "bool" - wfVariableKeyCertificateDaysLeft = "certificate.daysLeft" // ValueType: "int32" - wfVariableKeyNodeSkipped = "node.skipped" // ValueType: "bool" -) diff --git a/internal/workflow/engine/engine.go b/internal/workflow/engine/engine.go index ab3bbad16..fbd039bf2 100644 --- a/internal/workflow/engine/engine.go +++ b/internal/workflow/engine/engine.go @@ -52,8 +52,8 @@ func (we *workflowEngine) Invoke(ctx context.Context, workflowId string, runId s wfCtx := (&WorkflowContext{}). SetExecutingWorkflow(workflowId, runId, runGraph). SetEngine(we). - SetVariablesManager(newWorkflowIOsManager()). - SetInputsManager(newWorkflowIOsManager()). + SetVariablesManager(newStateManager()). + SetInputsManager(newStateManager()). SetContext(ctx) if err := we.executeBlocks(wfCtx, runGraph.Nodes); err != nil { if !errors.Is(err, errInterrupted) { diff --git a/internal/workflow/engine/executor.go b/internal/workflow/engine/executor.go index b664688fd..763f6e828 100644 --- a/internal/workflow/engine/executor.go +++ b/internal/workflow/engine/executor.go @@ -41,13 +41,13 @@ func (c *NodeExecutionContext) SetEngine(engine WorkflowEngine) *NodeExecutionCo return c } -func (c *NodeExecutionContext) SetVariablesManager(variablesMgr WorkflowIOsManager) *NodeExecutionContext { - c.variables = variablesMgr +func (c *NodeExecutionContext) SetVariablesManager(manager StateManager) *NodeExecutionContext { + c.variables = manager return c } -func (c *NodeExecutionContext) SetInputsManager(inputsMgr WorkflowIOsManager) *NodeExecutionContext { - c.inputs = inputsMgr +func (c *NodeExecutionContext) SetInputsManager(manager StateManager) *NodeExecutionContext { + c.inputs = manager return c } @@ -60,13 +60,13 @@ type NodeExecutionResult struct { Interrupted bool // 是否中断执行(通常由 End 节点主动触发) variablesMtx sync.RWMutex - Variables []NodeIOEntry + Variables []StateEntry outputsMtx sync.RWMutex - Outputs []NodeIOEntry + Outputs []StateEntry } func (r *NodeExecutionResult) AddVariable(scope string, key string, value any, valueType string) { - r.AddVariableEntry(NodeIOEntry{ + r.AddVariableEntry(StateEntry{ Scope: scope, Type: "", Key: key, @@ -75,12 +75,12 @@ func (r *NodeExecutionResult) AddVariable(scope string, key string, value any, v }) } -func (r *NodeExecutionResult) AddVariableEntry(entry NodeIOEntry) { +func (r *NodeExecutionResult) AddVariableEntry(entry StateEntry) { r.variablesMtx.Lock() defer r.variablesMtx.Unlock() if r.Variables == nil { - r.Variables = make([]NodeIOEntry, 0) + r.Variables = make([]StateEntry, 0) } for i, item := range r.Variables { @@ -93,7 +93,7 @@ func (r *NodeExecutionResult) AddVariableEntry(entry NodeIOEntry) { } func (r *NodeExecutionResult) AddOutput(scope string, type_ string, key string, value any, valueType string) { - r.AddOutputEntry(NodeIOEntry{ + r.AddOutputEntry(StateEntry{ Scope: scope, Type: type_, Key: key, @@ -102,12 +102,12 @@ func (r *NodeExecutionResult) AddOutput(scope string, type_ string, key string, }) } -func (r *NodeExecutionResult) AddOutputEntry(entry NodeIOEntry) { +func (r *NodeExecutionResult) AddOutputEntry(entry StateEntry) { r.outputsMtx.Lock() defer r.outputsMtx.Unlock() if r.Outputs == nil { - r.Outputs = make([]NodeIOEntry, 0) + r.Outputs = make([]StateEntry, 0) } for i, t := range r.Outputs { diff --git a/internal/workflow/engine/executor_bizapply.go b/internal/workflow/engine/executor_bizapply.go index 4e8010e85..b70ded109 100644 --- a/internal/workflow/engine/executor_bizapply.go +++ b/internal/workflow/engine/executor_bizapply.go @@ -35,7 +35,7 @@ func (ne *bizApplyNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExe if skippable, reason := ne.checkCanSkip(execCtx, lastOutput, lastCertificate); skippable { ne.logger.Info(fmt.Sprintf("skip this application, because %s", reason)) - execRes.AddVariable(execCtx.Node.Id, wfVariableKeyNodeSkipped, true, "boolean") + execRes.AddVariable(execCtx.Node.Id, stateVariableKeyNodeSkipped, true, "boolean") return execRes, nil } else if reason != "" { ne.logger.Info(fmt.Sprintf("re-apply, because %s", reason)) @@ -109,9 +109,9 @@ func (ne *bizApplyNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExe } // 记录中间结果 - execRes.AddVariable(execCtx.Node.Id, wfVariableKeyNodeSkipped, false, "boolean") - execRes.AddVariable(execCtx.Node.Id, wfVariableKeyCertificateValidity, true, "boolean") - execRes.AddVariable(execCtx.Node.Id, wfVariableKeyCertificateDaysLeft, int32(time.Until(certificate.ValidityNotAfter).Hours()/24), "number") + execRes.AddVariable(execCtx.Node.Id, stateVariableKeyNodeSkipped, false, "boolean") + execRes.AddVariable(execCtx.Node.Id, stateVariableKeyCertificateValidity, true, "boolean") + execRes.AddVariable(execCtx.Node.Id, stateVariableKeyCertificateDaysLeft, int32(time.Until(certificate.ValidityNotAfter).Hours()/24), "number") ne.logger.Info("application completed") return execRes, nil diff --git a/internal/workflow/engine/executor_bizdeploy.go b/internal/workflow/engine/executor_bizdeploy.go index 048e9a34d..d3798738e 100644 --- a/internal/workflow/engine/executor_bizdeploy.go +++ b/internal/workflow/engine/executor_bizdeploy.go @@ -42,7 +42,7 @@ func (ne *bizDeployNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx if skippable, reason := ne.checkCanSkip(execCtx, lastOutput); skippable { ne.logger.Info(fmt.Sprintf("skip this deployment, because %s", reason)) - execRes.AddVariable(execCtx.Node.Id, wfVariableKeyNodeSkipped, true, "boolean") + execRes.AddVariable(execCtx.Node.Id, stateVariableKeyNodeSkipped, true, "boolean") return execRes, nil } else if reason != "" { ne.logger.Info(fmt.Sprintf("re-deploy, because %s", reason)) @@ -82,7 +82,7 @@ func (ne *bizDeployNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx } // 记录中间结果 - execRes.AddVariable(execCtx.Node.Id, wfVariableKeyNodeSkipped, false, "boolean") + execRes.AddVariable(execCtx.Node.Id, stateVariableKeyNodeSkipped, false, "boolean") ne.logger.Info("deployment completed") return execRes, nil diff --git a/internal/workflow/engine/executor_bizmonitor.go b/internal/workflow/engine/executor_bizmonitor.go index 2e96aa08c..14bd2cc76 100644 --- a/internal/workflow/engine/executor_bizmonitor.go +++ b/internal/workflow/engine/executor_bizmonitor.go @@ -68,8 +68,8 @@ func (ne *bizMonitorNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeE if len(certs) == 0 { ne.logger.Warn("no ssl certificates retrieved in http response") - execRes.AddVariable(execCtx.Node.Id, wfVariableKeyCertificateValidity, false, "boolean") - execRes.AddVariable(execCtx.Node.Id, wfVariableKeyCertificateDaysLeft, 0, "number") + execRes.AddVariable(execCtx.Node.Id, stateVariableKeyCertificateValidity, false, "boolean") + execRes.AddVariable(execCtx.Node.Id, stateVariableKeyCertificateDaysLeft, 0, "number") } else { cert := certs[0] // 只取证书链中的第一个证书,即服务器证书 ne.logger.Info(fmt.Sprintf("ssl certificate retrieved (serial='%s', subject='%s', issuer='%s', not_before='%s', not_after='%s', sans='%s')", @@ -87,8 +87,8 @@ func (ne *bizMonitorNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeE validated := isCertPeriodValid && isCertHostMatched daysLeft := int(math.Floor(cert.NotAfter.Sub(now).Hours() / 24)) - execRes.AddVariable(execCtx.Node.Id, wfVariableKeyCertificateValidity, validated, "boolean") - execRes.AddVariable(execCtx.Node.Id, wfVariableKeyCertificateDaysLeft, daysLeft, "number") + execRes.AddVariable(execCtx.Node.Id, stateVariableKeyCertificateValidity, validated, "boolean") + execRes.AddVariable(execCtx.Node.Id, stateVariableKeyCertificateDaysLeft, daysLeft, "number") if validated { ne.logger.Info(fmt.Sprintf("the certificate is valid, and will expire in %d day(s)", daysLeft)) diff --git a/internal/workflow/engine/executor_biznotify.go b/internal/workflow/engine/executor_biznotify.go index af629f30b..8cd872c3e 100644 --- a/internal/workflow/engine/executor_biznotify.go +++ b/internal/workflow/engine/executor_biznotify.go @@ -56,7 +56,7 @@ func (ne *bizNotifyNodeExecutor) checkCanSkip(execCtx *NodeExecutionContext) (_s var total, skipped int32 for _, variable := range execCtx.variables.All() { - if variable.Scope != "" && variable.Key == wfVariableKeyNodeSkipped { + if variable.Scope != "" && variable.Key == stateVariableKeyNodeSkipped { total++ if variable.Value == true { skipped++ diff --git a/internal/workflow/engine/executor_bizupload.go b/internal/workflow/engine/executor_bizupload.go index e3afdc77f..80dce6fe4 100644 --- a/internal/workflow/engine/executor_bizupload.go +++ b/internal/workflow/engine/executor_bizupload.go @@ -33,7 +33,7 @@ func (ne *bizUploadNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx if skippable, reason := ne.checkCanSkip(execCtx, lastOutput, lastCertificate); skippable { ne.logger.Info(fmt.Sprintf("skip this uploading, because %s", reason)) - execRes.AddVariable(execCtx.Node.Id, wfVariableKeyNodeSkipped, true, "boolean") + execRes.AddVariable(execCtx.Node.Id, stateVariableKeyNodeSkipped, true, "boolean") return execRes, nil } else if reason != "" { ne.logger.Info(fmt.Sprintf("re-upload, because %s", reason)) @@ -70,9 +70,9 @@ func (ne *bizUploadNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx } // 记录中间结果 - execRes.AddVariable(execCtx.Node.Id, wfVariableKeyNodeSkipped, false, "boolean") - execRes.AddVariable(execCtx.Node.Id, wfVariableKeyCertificateValidity, true, "boolean") - execRes.AddVariable(execCtx.Node.Id, wfVariableKeyCertificateDaysLeft, int32(time.Until(certificate.ValidityNotAfter).Hours()/24), "number") + execRes.AddVariable(execCtx.Node.Id, stateVariableKeyNodeSkipped, false, "boolean") + execRes.AddVariable(execCtx.Node.Id, stateVariableKeyCertificateValidity, true, "boolean") + execRes.AddVariable(execCtx.Node.Id, stateVariableKeyCertificateDaysLeft, int32(time.Until(certificate.ValidityNotAfter).Hours()/24), "number") ne.logger.Info("uploading completed") return execRes, nil diff --git a/internal/workflow/engine/executor_condition.go b/internal/workflow/engine/executor_condition.go index f03de4c92..01aca9528 100644 --- a/internal/workflow/engine/executor_condition.go +++ b/internal/workflow/engine/executor_condition.go @@ -65,7 +65,7 @@ func (ne *branchBlockNodeExecutor) Execute(execCtx *NodeExecutionContext) (*Node if nodeCfg.Expression == nil { ne.logger.Info("enter this branch without any conditions") } else { - variables := lo.Reduce(execCtx.variables.All(), func(acc map[string]map[string]any, entry NodeIOEntry, _ int) map[string]map[string]any { + variables := lo.Reduce(execCtx.variables.All(), func(acc map[string]map[string]any, entry StateEntry, _ int) map[string]map[string]any { if _, ok := acc[entry.Scope]; !ok { acc[entry.Scope] = make(map[string]any) } diff --git a/internal/workflow/engine/models.go b/internal/workflow/engine/models.go index 63765f26c..079b676c5 100644 --- a/internal/workflow/engine/models.go +++ b/internal/workflow/engine/models.go @@ -23,12 +23,4 @@ const ( NodeTypeBizNotify = domain.WorkflowNodeTypeBizNotify ) -type NodeIOEntry struct { - Scope string // 零值时表示全局的,否则表示指定节点的 - Type string // 仅表示输入输出有值,表示变量无值 - Key string - Value any - ValueType string `options:"string | number | boolean"` -} - type Graph = domain.WorkflowGraph diff --git a/internal/workflow/engine/state.go b/internal/workflow/engine/state.go new file mode 100644 index 000000000..3274a4f49 --- /dev/null +++ b/internal/workflow/engine/state.go @@ -0,0 +1,139 @@ +package engine + +import ( + "slices" + "sync" +) + +type StateEntry struct { + Scope string // 零值时表示全局的,否则表示指定节点的 + Type string // 仅表示输入输出有值,表示变量无值 + Key string + Value any + ValueType string `options:"string | number | boolean"` +} + +type StateManager interface { + All() []StateEntry + Erase() + + Add(entry StateEntry) + Get(name string) (*StateEntry, bool) + GetScoped(scope string, name string) (*StateEntry, bool) + Take(name string) (*StateEntry, bool) + TakeScoped(scope string, name string) (*StateEntry, bool) + Remove(name string) bool + RemoveScoped(scope string, name string) bool +} + +type stateManager struct { + entriesMtx sync.RWMutex + entries []StateEntry +} + +var _ StateManager = (*stateManager)(nil) + +func (m *stateManager) All() []StateEntry { + m.entriesMtx.RLock() + defer m.entriesMtx.RUnlock() + + if m.entries == nil { + return make([]StateEntry, 0) + } + + return slices.Clone(m.entries) +} + +func (m *stateManager) Erase() { + m.entriesMtx.Lock() + defer m.entriesMtx.Unlock() + + m.entries = make([]StateEntry, 0) +} + +func (m *stateManager) Add(entry StateEntry) { + m.entriesMtx.Lock() + defer m.entriesMtx.Unlock() + + if m.entries == nil { + m.entries = make([]StateEntry, 0) + } + + for i, item := range m.entries { + if item.Scope == entry.Scope && item.Key == entry.Key { + m.entries[i] = entry + return + } + } + m.entries = append(m.entries, entry) +} + +func (m *stateManager) Get(name string) (*StateEntry, bool) { + return m.GetScoped("", name) +} + +func (m *stateManager) GetScoped(scope string, name string) (*StateEntry, bool) { + m.entriesMtx.RLock() + defer m.entriesMtx.RUnlock() + + if m.entries == nil { + return nil, false + } + + for _, item := range m.entries { + if item.Scope == scope && item.Key == name { + return &item, true + } + } + return nil, false +} + +func (m *stateManager) Take(name string) (*StateEntry, bool) { + return m.TakeScoped("", name) +} + +func (m *stateManager) TakeScoped(scope, name string) (*StateEntry, bool) { + m.entriesMtx.Lock() + defer m.entriesMtx.Unlock() + + if m.entries == nil { + return nil, false + } + + for i, item := range m.entries { + if item.Scope == scope && item.Key == name { + m.entries = slices.Delete(m.entries, i, i+1) + return &item, true + } + } + return nil, false +} + +func (m *stateManager) Remove(name string) bool { + return m.RemoveScoped("", name) +} + +func (m *stateManager) RemoveScoped(scope string, name string) bool { + _, ok := m.TakeScoped(scope, name) + return ok +} + +func newStateManager() StateManager { + return &stateManager{ + entries: make([]StateEntry, 0), + } +} + +const ( + stateIOTypeCertificate = "certificate" +) + +const ( + stateIOKeyCertificate = "certificate" +) + +const ( + stateVariableKeyCertificateValidity = "certificate.validity" // ValueType: "bool" + stateVariableKeyCertificateDaysLeft = "certificate.daysLeft" // ValueType: "int32" + stateVariableKeyNodeSkipped = "node.skipped" // ValueType: "bool" +) From 28eb7d2bf9bac72d11565170eb1aeae99e31f957 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Thu, 21 Aug 2025 12:38:32 +0800 Subject: [PATCH 16/20] refactor: standardize states store in workflow node executors --- internal/workflow/engine/executor_bizapply.go | 17 +++++++++-------- internal/workflow/engine/executor_bizdeploy.go | 4 ++-- internal/workflow/engine/executor_bizmonitor.go | 10 +++++----- internal/workflow/engine/executor_biznotify.go | 2 +- internal/workflow/engine/executor_bizupload.go | 15 +++++++-------- internal/workflow/engine/state.go | 6 +++--- 6 files changed, 27 insertions(+), 27 deletions(-) diff --git a/internal/workflow/engine/executor_bizapply.go b/internal/workflow/engine/executor_bizapply.go index b70ded109..22cc15a8a 100644 --- a/internal/workflow/engine/executor_bizapply.go +++ b/internal/workflow/engine/executor_bizapply.go @@ -4,6 +4,7 @@ import ( "fmt" "log/slog" "maps" + "math" "time" "github.com/certimate-go/certimate/internal/applicant" @@ -29,13 +30,16 @@ func (ne *bizApplyNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExe lastOutput, lastCertificate, err := ne.getLastOutputArtifacts(execCtx) if err != nil { return execRes, err + } else if lastCertificate != nil { + execRes.AddVariable(execCtx.Node.Id, stateVarKeyCertificateValidity, time.Now().After(lastCertificate.ValidityNotAfter), "boolean") + execRes.AddVariable(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, int32(time.Until(lastCertificate.ValidityNotAfter).Hours()/24), "number") } // 检测是否可以跳过本次执行 if skippable, reason := ne.checkCanSkip(execCtx, lastOutput, lastCertificate); skippable { ne.logger.Info(fmt.Sprintf("skip this application, because %s", reason)) - execRes.AddVariable(execCtx.Node.Id, stateVariableKeyNodeSkipped, true, "boolean") + execRes.AddVariable(execCtx.Node.Id, stateVarKeyNodeSkipped, true, "boolean") return execRes, nil } else if reason != "" { ne.logger.Info(fmt.Sprintf("re-apply, because %s", reason)) @@ -109,9 +113,9 @@ func (ne *bizApplyNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExe } // 记录中间结果 - execRes.AddVariable(execCtx.Node.Id, stateVariableKeyNodeSkipped, false, "boolean") - execRes.AddVariable(execCtx.Node.Id, stateVariableKeyCertificateValidity, true, "boolean") - execRes.AddVariable(execCtx.Node.Id, stateVariableKeyCertificateDaysLeft, int32(time.Until(certificate.ValidityNotAfter).Hours()/24), "number") + execRes.AddVariable(execCtx.Node.Id, stateVarKeyNodeSkipped, false, "boolean") + execRes.AddVariable(execCtx.Node.Id, stateVarKeyCertificateValidity, true, "boolean") + execRes.AddVariable(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, int32(time.Until(certificate.ValidityNotAfter).Hours()/24), "number") ne.logger.Info("application completed") return execRes, nil @@ -174,11 +178,8 @@ func (ne *bizApplyNodeExecutor) checkCanSkip(execCtx *NodeExecutionContext, last if lastCertificate != nil { renewalInterval := time.Duration(thisNodeCfg.SkipBeforeExpiryDays) * time.Hour * 24 expirationTime := time.Until(lastCertificate.ValidityNotAfter) - daysLeft := int(expirationTime.Hours() / 24) + daysLeft := int(math.Floor(expirationTime.Hours() / 24)) if expirationTime > renewalInterval { - // TODO: 优化此处逻辑,[checkCanSkip] 方法不应该修改中间结果,违背单一职责 - // ne.outputs[variableKeyCertificateValidity] = strconv.FormatBool(true) - // ne.outputs[variableKeyCertificateDaysLeft] = strconv.FormatInt(int64(daysLeft), 10) return true, fmt.Sprintf("the last issued certificate #%s expires in %d day(s), next renewal will be in %d day(s)", lastCertificate.Id, daysLeft, thisNodeCfg.SkipBeforeExpiryDays) } diff --git a/internal/workflow/engine/executor_bizdeploy.go b/internal/workflow/engine/executor_bizdeploy.go index d3798738e..1102a944e 100644 --- a/internal/workflow/engine/executor_bizdeploy.go +++ b/internal/workflow/engine/executor_bizdeploy.go @@ -42,7 +42,7 @@ func (ne *bizDeployNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx if skippable, reason := ne.checkCanSkip(execCtx, lastOutput); skippable { ne.logger.Info(fmt.Sprintf("skip this deployment, because %s", reason)) - execRes.AddVariable(execCtx.Node.Id, stateVariableKeyNodeSkipped, true, "boolean") + execRes.AddVariable(execCtx.Node.Id, stateVarKeyNodeSkipped, true, "boolean") return execRes, nil } else if reason != "" { ne.logger.Info(fmt.Sprintf("re-deploy, because %s", reason)) @@ -82,7 +82,7 @@ func (ne *bizDeployNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx } // 记录中间结果 - execRes.AddVariable(execCtx.Node.Id, stateVariableKeyNodeSkipped, false, "boolean") + execRes.AddVariable(execCtx.Node.Id, stateVarKeyNodeSkipped, false, "boolean") ne.logger.Info("deployment completed") return execRes, nil diff --git a/internal/workflow/engine/executor_bizmonitor.go b/internal/workflow/engine/executor_bizmonitor.go index 14bd2cc76..b1ed5310e 100644 --- a/internal/workflow/engine/executor_bizmonitor.go +++ b/internal/workflow/engine/executor_bizmonitor.go @@ -68,8 +68,8 @@ func (ne *bizMonitorNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeE if len(certs) == 0 { ne.logger.Warn("no ssl certificates retrieved in http response") - execRes.AddVariable(execCtx.Node.Id, stateVariableKeyCertificateValidity, false, "boolean") - execRes.AddVariable(execCtx.Node.Id, stateVariableKeyCertificateDaysLeft, 0, "number") + execRes.AddVariable(execCtx.Node.Id, stateVarKeyCertificateValidity, false, "boolean") + execRes.AddVariable(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, 0, "number") } else { cert := certs[0] // 只取证书链中的第一个证书,即服务器证书 ne.logger.Info(fmt.Sprintf("ssl certificate retrieved (serial='%s', subject='%s', issuer='%s', not_before='%s', not_after='%s', sans='%s')", @@ -86,9 +86,9 @@ func (ne *bizMonitorNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeE } validated := isCertPeriodValid && isCertHostMatched - daysLeft := int(math.Floor(cert.NotAfter.Sub(now).Hours() / 24)) - execRes.AddVariable(execCtx.Node.Id, stateVariableKeyCertificateValidity, validated, "boolean") - execRes.AddVariable(execCtx.Node.Id, stateVariableKeyCertificateDaysLeft, daysLeft, "number") + daysLeft := int(math.Floor(time.Until(cert.NotAfter).Hours() / 24)) + execRes.AddVariable(execCtx.Node.Id, stateVarKeyCertificateValidity, validated, "boolean") + execRes.AddVariable(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, daysLeft, "number") if validated { ne.logger.Info(fmt.Sprintf("the certificate is valid, and will expire in %d day(s)", daysLeft)) diff --git a/internal/workflow/engine/executor_biznotify.go b/internal/workflow/engine/executor_biznotify.go index 8cd872c3e..aff85b9b4 100644 --- a/internal/workflow/engine/executor_biznotify.go +++ b/internal/workflow/engine/executor_biznotify.go @@ -56,7 +56,7 @@ func (ne *bizNotifyNodeExecutor) checkCanSkip(execCtx *NodeExecutionContext) (_s var total, skipped int32 for _, variable := range execCtx.variables.All() { - if variable.Scope != "" && variable.Key == stateVariableKeyNodeSkipped { + if variable.Scope != "" && variable.Key == stateVarKeyNodeSkipped { total++ if variable.Value == true { skipped++ diff --git a/internal/workflow/engine/executor_bizupload.go b/internal/workflow/engine/executor_bizupload.go index 80dce6fe4..ab2c808f1 100644 --- a/internal/workflow/engine/executor_bizupload.go +++ b/internal/workflow/engine/executor_bizupload.go @@ -27,13 +27,16 @@ func (ne *bizUploadNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx lastOutput, lastCertificate, err := ne.getLastOutputArtifacts(execCtx) if err != nil { return execRes, err + } else if lastCertificate != nil { + execRes.AddVariable(execCtx.Node.Id, stateVarKeyCertificateValidity, time.Now().After(lastCertificate.ValidityNotAfter), "boolean") + execRes.AddVariable(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, int32(time.Until(lastCertificate.ValidityNotAfter).Hours()/24), "number") } // 检测是否可以跳过本次执行 if skippable, reason := ne.checkCanSkip(execCtx, lastOutput, lastCertificate); skippable { ne.logger.Info(fmt.Sprintf("skip this uploading, because %s", reason)) - execRes.AddVariable(execCtx.Node.Id, stateVariableKeyNodeSkipped, true, "boolean") + execRes.AddVariable(execCtx.Node.Id, stateVarKeyNodeSkipped, true, "boolean") return execRes, nil } else if reason != "" { ne.logger.Info(fmt.Sprintf("re-upload, because %s", reason)) @@ -70,9 +73,9 @@ func (ne *bizUploadNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx } // 记录中间结果 - execRes.AddVariable(execCtx.Node.Id, stateVariableKeyNodeSkipped, false, "boolean") - execRes.AddVariable(execCtx.Node.Id, stateVariableKeyCertificateValidity, true, "boolean") - execRes.AddVariable(execCtx.Node.Id, stateVariableKeyCertificateDaysLeft, int32(time.Until(certificate.ValidityNotAfter).Hours()/24), "number") + execRes.AddVariable(execCtx.Node.Id, stateVarKeyNodeSkipped, false, "boolean") + execRes.AddVariable(execCtx.Node.Id, stateVarKeyCertificateValidity, true, "boolean") + execRes.AddVariable(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, int32(time.Until(certificate.ValidityNotAfter).Hours()/24), "number") ne.logger.Info("uploading completed") return execRes, nil @@ -112,10 +115,6 @@ func (ne *bizUploadNodeExecutor) checkCanSkip(execCtx *NodeExecutionContext, las } if lastCertificate != nil { - // TODO: 优化此处逻辑,[checkCanSkip] 方法不应该修改中间结果,违背单一职责 - // daysLeft := int(time.Until(lastCertificate.ValidityNotAfter).Hours() / 24) - // execCtx.SetInputEntry(execCtx.Node.Id, variableKeyCertificateValidity, strconv.FormatBool(daysLeft > 0)) - // execCtx.SetInputEntry(execCtx.Node.Id, variableKeyCertificateDaysLeft, strconv.FormatInt(int64(daysLeft), 10)) return true, "the last uploaded certificate already exists" } diff --git a/internal/workflow/engine/state.go b/internal/workflow/engine/state.go index 3274a4f49..4d38f6a09 100644 --- a/internal/workflow/engine/state.go +++ b/internal/workflow/engine/state.go @@ -133,7 +133,7 @@ const ( ) const ( - stateVariableKeyCertificateValidity = "certificate.validity" // ValueType: "bool" - stateVariableKeyCertificateDaysLeft = "certificate.daysLeft" // ValueType: "int32" - stateVariableKeyNodeSkipped = "node.skipped" // ValueType: "bool" + stateVarKeyNodeSkipped = "node.skipped" // ValueType: "boolean" + stateVarKeyCertificateValidity = "certificate.validity" // ValueType: "boolean" + stateVarKeyCertificateDaysLeft = "certificate.daysLeft" // ValueType: "number" ) From a7b4e921d0e3b264a3bfd4922092ebc96f6fdec0 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Thu, 21 Aug 2025 19:18:32 +0800 Subject: [PATCH 17/20] refactor: standardize outputs store in workflow node executors --- internal/domain/certificate.go | 1 - internal/domain/workflow_output.go | 2 +- internal/repository/certificate.go | 2 - internal/repository/workflow_output.go | 51 ---- internal/workflow/engine/context.go | 10 +- internal/workflow/engine/engine.go | 76 +++-- internal/workflow/engine/executor.go | 99 +++++-- internal/workflow/engine/executor_bizapply.go | 57 ++-- .../workflow/engine/executor_bizdeploy.go | 55 ++-- .../workflow/engine/executor_bizmonitor.go | 15 +- .../workflow/engine/executor_biznotify.go | 2 +- .../workflow/engine/executor_bizupload.go | 58 ++-- .../workflow/engine/executor_condition.go | 22 +- internal/workflow/engine/executor_end.go | 2 +- internal/workflow/engine/executor_start.go | 2 +- internal/workflow/engine/executor_trycatch.go | 6 +- internal/workflow/engine/services.go | 3 +- internal/workflow/engine/state.go | 272 +++++++++++++----- migrations/1755187200_cm0.4.0_migrate.go | 27 +- 19 files changed, 451 insertions(+), 311 deletions(-) diff --git a/internal/domain/certificate.go b/internal/domain/certificate.go index e069ff7e1..4c4266e8d 100644 --- a/internal/domain/certificate.go +++ b/internal/domain/certificate.go @@ -31,7 +31,6 @@ type Certificate struct { ACMERenewed bool `json:"acmeRenewed" db:"acmeRenewed"` WorkflowId string `json:"workflowId" db:"workflowRef"` WorkflowRunId string `json:"workflowRunId" db:"workflowRunRef"` - WorkflowOutputId string `json:"workflowOutputId" db:"workflowOutputRef"` WorkflowNodeId string `json:"workflowNodeId" db:"workflowNodeId"` DeletedAt *time.Time `json:"deleted" db:"deleted"` } diff --git a/internal/domain/workflow_output.go b/internal/domain/workflow_output.go index 28933832e..b83c14bb1 100644 --- a/internal/domain/workflow_output.go +++ b/internal/domain/workflow_output.go @@ -15,6 +15,6 @@ type WorkflowOutput struct { type WorkflowOutputEntry struct { Type string `json:"type"` Name string `json:"name"` - Value any `json:"value"` + Value string `json:"value"` ValueType string `json:"valueType"` } diff --git a/internal/repository/certificate.go b/internal/repository/certificate.go index 7447bf632..a8152ab23 100644 --- a/internal/repository/certificate.go +++ b/internal/repository/certificate.go @@ -133,7 +133,6 @@ func (r *CertificateRepository) Save(ctx context.Context, certificate *domain.Ce record.Set("acmeRenewed", certificate.ACMERenewed) record.Set("workflowRef", certificate.WorkflowId) record.Set("workflowRunRef", certificate.WorkflowRunId) - record.Set("workflowOutputRef", certificate.WorkflowOutputId) record.Set("workflowNodeId", certificate.WorkflowNodeId) if err := app.GetApp().Save(record); err != nil { return certificate, err @@ -195,7 +194,6 @@ func (r *CertificateRepository) castRecordToModel(record *core.Record) (*domain. ACMERenewed: record.GetBool("acmeRenewed"), WorkflowId: record.GetString("workflowRef"), WorkflowRunId: record.GetString("workflowRunRef"), - WorkflowOutputId: record.GetString("workflowOutputRef"), WorkflowNodeId: record.GetString("workflowNodeId"), } return certificate, nil diff --git a/internal/repository/workflow_output.go b/internal/repository/workflow_output.go index f1b4b0f24..e994bad21 100644 --- a/internal/repository/workflow_output.go +++ b/internal/repository/workflow_output.go @@ -51,57 +51,6 @@ func (r *WorkflowOutputRepository) Save(ctx context.Context, workflowOutput *dom return workflowOutput, nil } -func (r *WorkflowOutputRepository) SaveWithCertificate(ctx context.Context, workflowOutput *domain.WorkflowOutput, certificate *domain.Certificate) (*domain.WorkflowOutput, error) { - record, err := r.saveRecord(workflowOutput) - if err != nil { - return workflowOutput, err - } else { - workflowOutput.Id = record.Id - workflowOutput.CreatedAt = record.GetDateTime("created").Time() - workflowOutput.UpdatedAt = record.GetDateTime("updated").Time() - } - - if certificate == nil { - panic("certificate is nil") - } else { - if certificate.WorkflowId != "" && certificate.WorkflowId != workflowOutput.WorkflowId { - return workflowOutput, fmt.Errorf("certificate #%s is not belong to workflow #%s", certificate.Id, workflowOutput.WorkflowId) - } - if certificate.WorkflowRunId != "" && certificate.WorkflowRunId != workflowOutput.RunId { - return workflowOutput, fmt.Errorf("certificate #%s is not belong to workflow run #%s", certificate.Id, workflowOutput.RunId) - } - if certificate.WorkflowNodeId != "" && certificate.WorkflowNodeId != workflowOutput.NodeId { - return workflowOutput, fmt.Errorf("certificate #%s is not belong to workflow node #%s", certificate.Id, workflowOutput.NodeId) - } - if certificate.WorkflowOutputId != "" && certificate.WorkflowOutputId != workflowOutput.Id { - return workflowOutput, fmt.Errorf("certificate #%s is not belong to workflow output #%s", certificate.Id, workflowOutput.Id) - } - - certificate.WorkflowId = workflowOutput.WorkflowId - certificate.WorkflowRunId = workflowOutput.RunId - certificate.WorkflowNodeId = workflowOutput.NodeId - certificate.WorkflowOutputId = workflowOutput.Id - certificate, err := NewCertificateRepository().Save(ctx, certificate) - if err != nil { - return workflowOutput, err - } - - // 写入证书 ID 到工作流输出结果中 - for i, item := range workflowOutput.Outputs { - if item.Type == "certificate" { - workflowOutput.Outputs[i].Value = certificate.Id - break - } - } - record.Set("outputs", workflowOutput.Outputs) - if err := app.GetApp().Save(record); err != nil { - return workflowOutput, err - } - } - - return workflowOutput, err -} - func (r *WorkflowOutputRepository) castRecordToModel(record *core.Record) (*domain.WorkflowOutput, error) { if record == nil { return nil, fmt.Errorf("the record is nil") diff --git a/internal/workflow/engine/context.go b/internal/workflow/engine/context.go index 06797d621..2efc7eafb 100644 --- a/internal/workflow/engine/context.go +++ b/internal/workflow/engine/context.go @@ -10,8 +10,8 @@ type WorkflowContext struct { RunGraph *Graph engine WorkflowEngine - variables StateManager - inputs StateManager + variables VariableManager + inputs InOutManager ctx context.Context } @@ -28,12 +28,12 @@ func (c *WorkflowContext) SetEngine(engine WorkflowEngine) *WorkflowContext { return c } -func (c *WorkflowContext) SetVariablesManager(manager StateManager) *WorkflowContext { - c.variables = manager +func (c *WorkflowContext) SetVariablesManager(inputs VariableManager) *WorkflowContext { + c.variables = inputs return c } -func (c *WorkflowContext) SetInputsManager(manager StateManager) *WorkflowContext { +func (c *WorkflowContext) SetInputsManager(manager InOutManager) *WorkflowContext { c.inputs = manager return c } diff --git a/internal/workflow/engine/engine.go b/internal/workflow/engine/engine.go index fbd039bf2..fa41f93bc 100644 --- a/internal/workflow/engine/engine.go +++ b/internal/workflow/engine/engine.go @@ -7,7 +7,11 @@ import ( "log/slog" "sync" + "github.com/samber/lo" + "github.com/certimate-go/certimate/internal/app" + "github.com/certimate-go/certimate/internal/domain" + "github.com/certimate-go/certimate/internal/repository" "github.com/certimate-go/certimate/pkg/logging" ) @@ -26,7 +30,7 @@ type WorkflowEngine interface { type workflowEngine struct { logger *slog.Logger - executorRegistry map[NodeType]NodeExecutor + executors map[NodeType]NodeExecutor hooksMtx sync.RWMutex onStartHooks [](func(ctx context.Context) error) @@ -36,6 +40,8 @@ type workflowEngine struct { onNodeEndHooks [](func(ctx context.Context, node *Node, res *NodeExecutionResult) error) onNodeErrorHooks [](func(ctx context.Context, node *Node, err error) error) onNodeLoggingHooks [](func(ctx context.Context, node *Node, log logging.Record) error) + + wfoutputRepo workflowOutputRepository } var _ WorkflowEngine = (*workflowEngine)(nil) @@ -52,8 +58,8 @@ func (we *workflowEngine) Invoke(ctx context.Context, workflowId string, runId s wfCtx := (&WorkflowContext{}). SetExecutingWorkflow(workflowId, runId, runGraph). SetEngine(we). - SetVariablesManager(newStateManager()). - SetInputsManager(newStateManager()). + SetVariablesManager(newVariableManager()). + SetInputsManager(newInOutManager()). SetContext(ctx) if err := we.executeBlocks(wfCtx, runGraph.Nodes); err != nil { if !errors.Is(err, errInterrupted) { @@ -110,7 +116,7 @@ func (we *workflowEngine) OnNodeLogging(callback func(ctx context.Context, node } func (we *workflowEngine) executeNode(wfCtx *WorkflowContext, node *Node) error { - executor, ok := we.executorRegistry[node.Type] + executor, ok := we.executors[node.Type] if !ok { err := fmt.Errorf("workflow engine: no executor registered for node type: '%s'", node.Type) return err @@ -125,16 +131,9 @@ func (we *workflowEngine) executeNode(wfCtx *WorkflowContext, node *Node) error executor.SetLogger(logger) } - execCtx := (&NodeExecutionContext{}). - SetExecutingWorkflow(wfCtx.WorkflowId, wfCtx.RunId, wfCtx.RunGraph). - SetExecutingNode(node). - SetEngine(we). - SetVariablesManager(wfCtx.variables). - SetInputsManager(wfCtx.inputs). - SetContext(wfCtx.ctx) - we.fireOnNodeStartHooks(wfCtx.ctx, node) + execCtx := newNodeExecutionContext(wfCtx, node) execRes, err := executor.Execute(execCtx) if err != nil { we.fireOnNodeErrorHooks(wfCtx.ctx, node, err) @@ -156,6 +155,30 @@ func (we *workflowEngine) executeNode(wfCtx *WorkflowContext, node *Node) error } } + execOutputs := lo.Filter(execRes.Outputs, func(state InOutState, _ int) bool { return state.Persistent }) + if execRes.outputForced || len(execOutputs) > 0 { + output := &domain.WorkflowOutput{ + WorkflowId: execCtx.WorkflowId, + RunId: execCtx.RunId, + NodeId: execCtx.Node.Id, + NodeConfig: execCtx.Node.Data.Config, + Succeeded: true, // 目前恒为 true + } + if len(execOutputs) > 0 { + output.Outputs = lo.Map(execOutputs, func(state InOutState, _ int) *domain.WorkflowOutputEntry { + return &domain.WorkflowOutputEntry{ + Name: state.Name, + Type: state.Type, + Value: state.ValueString(), + ValueType: state.ValueType, + } + }) + } + if _, err := we.wfoutputRepo.Save(execCtx.ctx, output); err != nil { + we.logger.Warn("failed to save node output") + } + } + if execRes.Interrupted { return errInterrupted } @@ -253,20 +276,21 @@ func (we *workflowEngine) fireOnNodeLoggingHooks(ctx context.Context, node *Node func NewWorkflowEngine() WorkflowEngine { engine := &workflowEngine{ - executorRegistry: make(map[NodeType]NodeExecutor), - logger: app.GetLogger(), + logger: app.GetLogger(), + executors: make(map[NodeType]NodeExecutor), + wfoutputRepo: repository.NewWorkflowOutputRepository(), } - engine.executorRegistry[NodeTypeStart] = newStartNodeExecutor() - engine.executorRegistry[NodeTypeEnd] = newEndNodeExecutor() - engine.executorRegistry[NodeTypeCondition] = newConditionNodeExecutor() - engine.executorRegistry[NodeTypeBranchBlock] = newBranchBlockNodeExecutor() - engine.executorRegistry[NodeTypeTryCatch] = newTryCatchNodeExecutor() - engine.executorRegistry[NodeTypeTryBlock] = newTryBlockNodeExecutor() - engine.executorRegistry[NodeTypeCatchBlock] = newCatchBlockNodeExecutor() - engine.executorRegistry[NodeTypeBizApply] = newBizApplyNodeExecutor() - engine.executorRegistry[NodeTypeBizUpload] = newBizUploadNodeExecutor() - engine.executorRegistry[NodeTypeBizMonitor] = newBizMonitorNodeExecutor() - engine.executorRegistry[NodeTypeBizDeploy] = newBizDeployNodeExecutor() - engine.executorRegistry[NodeTypeBizNotify] = newBizNotifyNodeExecutor() + engine.executors[NodeTypeStart] = newStartNodeExecutor() + engine.executors[NodeTypeEnd] = newEndNodeExecutor() + engine.executors[NodeTypeCondition] = newConditionNodeExecutor() + engine.executors[NodeTypeBranchBlock] = newBranchBlockNodeExecutor() + engine.executors[NodeTypeTryCatch] = newTryCatchNodeExecutor() + engine.executors[NodeTypeTryBlock] = newTryBlockNodeExecutor() + engine.executors[NodeTypeCatchBlock] = newCatchBlockNodeExecutor() + engine.executors[NodeTypeBizApply] = newBizApplyNodeExecutor() + engine.executors[NodeTypeBizUpload] = newBizUploadNodeExecutor() + engine.executors[NodeTypeBizMonitor] = newBizMonitorNodeExecutor() + engine.executors[NodeTypeBizDeploy] = newBizDeployNodeExecutor() + engine.executors[NodeTypeBizNotify] = newBizNotifyNodeExecutor() return engine } diff --git a/internal/workflow/engine/executor.go b/internal/workflow/engine/executor.go index 763f6e828..a55182bbf 100644 --- a/internal/workflow/engine/executor.go +++ b/internal/workflow/engine/executor.go @@ -41,13 +41,13 @@ func (c *NodeExecutionContext) SetEngine(engine WorkflowEngine) *NodeExecutionCo return c } -func (c *NodeExecutionContext) SetVariablesManager(manager StateManager) *NodeExecutionContext { - c.variables = manager +func (c *NodeExecutionContext) SetVariablesManager(variables VariableManager) *NodeExecutionContext { + c.WorkflowContext.SetVariablesManager(variables) return c } -func (c *NodeExecutionContext) SetInputsManager(manager StateManager) *NodeExecutionContext { - c.inputs = manager +func (c *NodeExecutionContext) SetInputsManager(inputs InOutManager) *NodeExecutionContext { + c.WorkflowContext.SetInputsManager(inputs) return c } @@ -56,65 +56,102 @@ func (c *NodeExecutionContext) SetContext(ctx context.Context) *NodeExecutionCon return c } -type NodeExecutionResult struct { - Interrupted bool // 是否中断执行(通常由 End 节点主动触发) - - variablesMtx sync.RWMutex - Variables []StateEntry - outputsMtx sync.RWMutex - Outputs []StateEntry +func newNodeExecutionContext(wfCtx *WorkflowContext, node *Node) *NodeExecutionContext { + return (&NodeExecutionContext{}). + SetExecutingWorkflow(wfCtx.WorkflowId, wfCtx.RunId, wfCtx.RunGraph). + SetExecutingNode(node). + SetEngine(wfCtx.engine). + SetVariablesManager(wfCtx.variables). + SetInputsManager(wfCtx.inputs). + SetContext(wfCtx.ctx) } -func (r *NodeExecutionResult) AddVariable(scope string, key string, value any, valueType string) { - r.AddVariableEntry(StateEntry{ +type NodeExecutionResult struct { + node *Node + + Interrupted bool // 是否中断执行(通常由 End 节点主动触发) + + variablesMtx sync.Mutex + Variables []VariableState + + outputForced bool // 即使 Outputs 为空,也强制持久化输出 + outputsMtx sync.Mutex + Outputs []InOutState +} + +func (r *NodeExecutionResult) AddVariable(key string, value any, valueType string) { + r.AddVariableWithScope("", key, value, valueType) +} + +func (r *NodeExecutionResult) AddVariableWithScope(scope string, key string, value any, valueType string) { + r.addVariableState(VariableState{ Scope: scope, - Type: "", Key: key, Value: value, ValueType: valueType, }) } -func (r *NodeExecutionResult) AddVariableEntry(entry StateEntry) { +func (r *NodeExecutionResult) addVariableState(state VariableState) { r.variablesMtx.Lock() defer r.variablesMtx.Unlock() if r.Variables == nil { - r.Variables = make([]StateEntry, 0) + r.Variables = make([]VariableState, 0) } for i, item := range r.Variables { - if item.Scope == entry.Scope && item.Key == entry.Key { - r.Variables[i] = entry + if item.Scope == state.Scope && item.Key == state.Key { + r.Variables[i] = state return } } - r.Variables = append(r.Variables, entry) + r.Variables = append(r.Variables, state) } -func (r *NodeExecutionResult) AddOutput(scope string, type_ string, key string, value any, valueType string) { - r.AddOutputEntry(StateEntry{ - Scope: scope, - Type: type_, - Key: key, - Value: value, - ValueType: valueType, +func (r *NodeExecutionResult) AddOutput(stype string, key string, value any, valueType string) { + r.addOutputState(InOutState{ + NodeId: r.node.Id, + Type: stype, + Name: key, + Value: value, + ValueType: valueType, + Persistent: false, }) } -func (r *NodeExecutionResult) AddOutputEntry(entry StateEntry) { +func (r *NodeExecutionResult) AddOutputWithPersistent(stype string, key string, value any, valueType string) { + r.addOutputState(InOutState{ + NodeId: r.node.Id, + Type: stype, + Name: key, + Value: value, + ValueType: valueType, + Persistent: true, + }) +} + +func (r *NodeExecutionResult) addOutputState(state InOutState) { r.outputsMtx.Lock() defer r.outputsMtx.Unlock() if r.Outputs == nil { - r.Outputs = make([]StateEntry, 0) + r.Outputs = make([]InOutState, 0) } for i, t := range r.Outputs { - if t.Scope == entry.Scope && t.Key == entry.Key { - r.Outputs[i] = entry + if t.NodeId == state.NodeId && t.Name == state.Name { + r.Outputs[i] = state return } } - r.Outputs = append(r.Outputs, entry) + r.Outputs = append(r.Outputs, state) +} + +func newNodeExecutionResult(node *Node) *NodeExecutionResult { + return &NodeExecutionResult{ + node: node, + Variables: make([]VariableState, 0), + Outputs: make([]InOutState, 0), + } } diff --git a/internal/workflow/engine/executor_bizapply.go b/internal/workflow/engine/executor_bizapply.go index 22cc15a8a..f69797c24 100644 --- a/internal/workflow/engine/executor_bizapply.go +++ b/internal/workflow/engine/executor_bizapply.go @@ -13,6 +13,15 @@ import ( xcert "github.com/certimate-go/certimate/pkg/utils/cert" ) +/** + * Result Variables: + * - node.skipped: boolean + * - certificate.validity: boolean + * - certificate.daysLeft: number + * + * Result Outputs: + * - ref: certificate: string + */ type bizApplyNodeExecutor struct { nodeExecutor @@ -21,7 +30,7 @@ type bizApplyNodeExecutor struct { } func (ne *bizApplyNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { - execRes := &NodeExecutionResult{} + execRes := newNodeExecutionResult(execCtx.Node) nodeCfg := execCtx.Node.Data.Config.AsBizApply() ne.logger.Info("ready to request certificate ...", slog.Any("config", nodeCfg)) @@ -31,15 +40,16 @@ func (ne *bizApplyNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExe if err != nil { return execRes, err } else if lastCertificate != nil { - execRes.AddVariable(execCtx.Node.Id, stateVarKeyCertificateValidity, time.Now().After(lastCertificate.ValidityNotAfter), "boolean") - execRes.AddVariable(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, int32(time.Until(lastCertificate.ValidityNotAfter).Hours()/24), "number") + execRes.AddOutput(stateIOTypeRef, "certificate", fmt.Sprintf("%s#%s", domain.CollectionNameCertificate, lastCertificate.Id), "string") + execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateValidity, time.Now().After(lastCertificate.ValidityNotAfter), "boolean") + execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, int32(time.Until(lastCertificate.ValidityNotAfter).Hours()/24), "number") } // 检测是否可以跳过本次执行 if skippable, reason := ne.checkCanSkip(execCtx, lastOutput, lastCertificate); skippable { ne.logger.Info(fmt.Sprintf("skip this application, because %s", reason)) - execRes.AddVariable(execCtx.Node.Id, stateVarKeyNodeSkipped, true, "boolean") + execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyNodeSkipped, true, "boolean") return execRes, nil } else if reason != "" { ne.logger.Info(fmt.Sprintf("re-apply, because %s", reason)) @@ -66,13 +76,14 @@ func (ne *bizApplyNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExe return execRes, err } - // 解析证书并生成实体 + // 解析证书 certX509, err := xcert.ParseCertificateFromPEM(applyResult.FullChainCertificate) if err != nil { ne.logger.Warn("failed to parse certificate, may be the CA responded error") return execRes, err } + // 保存证书实体 certificate := &domain.Certificate{ Source: domain.CertificateSourceTypeRequest, Certificate: applyResult.FullChainCertificate, @@ -81,29 +92,16 @@ func (ne *bizApplyNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExe ACMEAccountUrl: applyResult.ACMEAccountUrl, ACMECertUrl: applyResult.ACMECertUrl, ACMECertStableUrl: applyResult.ACMECertStableUrl, + WorkflowId: execCtx.WorkflowId, + WorkflowRunId: execCtx.RunId, + WorkflowNodeId: execCtx.Node.Id, } certificate.PopulateFromX509(certX509) - - // 保存执行结果 - // TODO: 解耦 - output := &domain.WorkflowOutput{ - WorkflowId: execCtx.WorkflowId, - RunId: execCtx.RunId, - NodeId: execCtx.Node.Id, - NodeConfig: execCtx.Node.Data.Config, - Succeeded: true, - Outputs: []*domain.WorkflowOutputEntry{ - { - Name: "certificate", - Type: "certificate", - Value: certificate.Id, - ValueType: "string", - }, - }, - } - if _, err := ne.wfoutputRepo.SaveWithCertificate(execCtx.ctx, output, certificate); err != nil { - ne.logger.Warn("failed to save node output") + if certificate, err := ne.certificateRepo.Save(execCtx.ctx, certificate); err != nil { + ne.logger.Warn("failed to save certificate") return execRes, err + } else { + ne.logger.Info("certificate saved", slog.String("recordId", certificate.Id)) } // 保存 ARI 记录 @@ -112,10 +110,11 @@ func (ne *bizApplyNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExe ne.certificateRepo.Save(execCtx.ctx, lastCertificate) } - // 记录中间结果 - execRes.AddVariable(execCtx.Node.Id, stateVarKeyNodeSkipped, false, "boolean") - execRes.AddVariable(execCtx.Node.Id, stateVarKeyCertificateValidity, true, "boolean") - execRes.AddVariable(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, int32(time.Until(certificate.ValidityNotAfter).Hours()/24), "number") + // 节点输出 + execRes.AddOutputWithPersistent(stateIOTypeRef, "certificate", fmt.Sprintf("%s#%s", domain.CollectionNameCertificate, certificate.Id), "string") + execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyNodeSkipped, false, "boolean") + execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateValidity, true, "boolean") + execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, int32(time.Until(certificate.ValidityNotAfter).Hours()/24), "number") ne.logger.Info("application completed") return execRes, nil diff --git a/internal/workflow/engine/executor_bizdeploy.go b/internal/workflow/engine/executor_bizdeploy.go index 1102a944e..73dc965be 100644 --- a/internal/workflow/engine/executor_bizdeploy.go +++ b/internal/workflow/engine/executor_bizdeploy.go @@ -4,12 +4,17 @@ import ( "fmt" "log/slog" "maps" + "strings" "github.com/certimate-go/certimate/internal/deployer" "github.com/certimate-go/certimate/internal/domain" "github.com/certimate-go/certimate/internal/repository" ) +/** + * Result Variables: + * - node.skipped: boolean + */ type bizDeployNodeExecutor struct { nodeExecutor @@ -18,7 +23,7 @@ type bizDeployNodeExecutor struct { } func (ne *bizDeployNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { - execRes := &NodeExecutionResult{} + execRes := newNodeExecutionResult(execCtx.Node) nodeCfg := execCtx.Node.Data.Config.AsBizDeploy() ne.logger.Info("ready to deploy certificate ...", slog.Any("config", nodeCfg)) @@ -30,19 +35,31 @@ func (ne *bizDeployNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx } // 获取前序节点输出证书 - // TODO: 利用输入而非查库 - certificate, err := ne.certificateRepo.GetByWorkflowIdAndNodeId(execCtx.ctx, execCtx.WorkflowId, nodeCfg.CertificateOutputNodeId) - if err != nil { - ne.logger.Warn("invalid certificate output") - return execRes, err + var inputCertificate *domain.Certificate + if inputState, ok := execCtx.inputs.Get(nodeCfg.CertificateOutputNodeId, "certificate"); ok { + if inputStateValue, ok := inputState.Value.(string); ok { + s := strings.Split(inputStateValue, "#") + if len(s) == 2 { + certificate, err := ne.certificateRepo.GetById(execCtx.ctx, s[1]) + if err != nil { + ne.logger.Warn("failed to get input certificate") + return execRes, err + } + + inputCertificate = certificate + } + } + } + if inputCertificate == nil { + return execRes, fmt.Errorf("invalid input certificate") } // 检测是否可以跳过本次执行 - if lastOutput != nil && certificate.CreatedAt.Before(lastOutput.UpdatedAt) { + if lastOutput != nil && inputCertificate.CreatedAt.Before(lastOutput.UpdatedAt) { if skippable, reason := ne.checkCanSkip(execCtx, lastOutput); skippable { ne.logger.Info(fmt.Sprintf("skip this deployment, because %s", reason)) - execRes.AddVariable(execCtx.Node.Id, stateVarKeyNodeSkipped, true, "boolean") + execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyNodeSkipped, true, "boolean") return execRes, nil } else if reason != "" { ne.logger.Info(fmt.Sprintf("re-deploy, because %s", reason)) @@ -54,8 +71,8 @@ func (ne *bizDeployNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx deployer, err := deployer.NewWithWorkflowNode(deployer.DeployerWithWorkflowNodeConfig{ Node: execCtx.Node, Logger: ne.logger, - CertificatePEM: certificate.Certificate, - PrivateKeyPEM: certificate.PrivateKey, + CertificatePEM: inputCertificate.Certificate, + PrivateKeyPEM: inputCertificate.PrivateKey, }) if err != nil { ne.logger.Warn("failed to create deployer provider") @@ -68,21 +85,9 @@ func (ne *bizDeployNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx return execRes, err } - // 保存执行结果 - output := &domain.WorkflowOutput{ - WorkflowId: execCtx.WorkflowId, - RunId: execCtx.RunId, - NodeId: execCtx.Node.Id, - NodeConfig: execCtx.Node.Data.Config, - Succeeded: true, - } - if _, err := ne.wfoutputRepo.Save(execCtx.ctx, output); err != nil { - ne.logger.Warn("failed to save node output") - return execRes, err - } - - // 记录中间结果 - execRes.AddVariable(execCtx.Node.Id, stateVarKeyNodeSkipped, false, "boolean") + // 节点输出 + execRes.outputForced = true + execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyNodeSkipped, false, "boolean") ne.logger.Info("deployment completed") return execRes, nil diff --git a/internal/workflow/engine/executor_bizmonitor.go b/internal/workflow/engine/executor_bizmonitor.go index b1ed5310e..d4616e012 100644 --- a/internal/workflow/engine/executor_bizmonitor.go +++ b/internal/workflow/engine/executor_bizmonitor.go @@ -16,6 +16,11 @@ import ( xtls "github.com/certimate-go/certimate/pkg/utils/tls" ) +/** + * Result Variables: + * - certificate.validity: boolean + * - certificate.daysLeft: number + */ type bizMonitorNodeExecutor struct { nodeExecutor @@ -23,7 +28,7 @@ type bizMonitorNodeExecutor struct { } func (ne *bizMonitorNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { - execRes := &NodeExecutionResult{} + execRes := newNodeExecutionResult(execCtx.Node) nodeCfg := execCtx.Node.Data.Config.AsBizMonitor() ne.logger.Info("ready to monitor certificate ...", slog.Any("config", nodeCfg)) @@ -68,8 +73,8 @@ func (ne *bizMonitorNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeE if len(certs) == 0 { ne.logger.Warn("no ssl certificates retrieved in http response") - execRes.AddVariable(execCtx.Node.Id, stateVarKeyCertificateValidity, false, "boolean") - execRes.AddVariable(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, 0, "number") + execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateValidity, false, "boolean") + execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, 0, "number") } else { cert := certs[0] // 只取证书链中的第一个证书,即服务器证书 ne.logger.Info(fmt.Sprintf("ssl certificate retrieved (serial='%s', subject='%s', issuer='%s', not_before='%s', not_after='%s', sans='%s')", @@ -87,8 +92,8 @@ func (ne *bizMonitorNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeE validated := isCertPeriodValid && isCertHostMatched daysLeft := int(math.Floor(time.Until(cert.NotAfter).Hours() / 24)) - execRes.AddVariable(execCtx.Node.Id, stateVarKeyCertificateValidity, validated, "boolean") - execRes.AddVariable(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, daysLeft, "number") + execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateValidity, validated, "boolean") + execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, daysLeft, "number") if validated { ne.logger.Info(fmt.Sprintf("the certificate is valid, and will expire in %d day(s)", daysLeft)) diff --git a/internal/workflow/engine/executor_biznotify.go b/internal/workflow/engine/executor_biznotify.go index aff85b9b4..c15c74ce5 100644 --- a/internal/workflow/engine/executor_biznotify.go +++ b/internal/workflow/engine/executor_biznotify.go @@ -15,7 +15,7 @@ type bizNotifyNodeExecutor struct { } func (ne *bizNotifyNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { - execRes := &NodeExecutionResult{} + execRes := newNodeExecutionResult(execCtx.Node) nodeCfg := execCtx.Node.Data.Config.AsBizNotify() ne.logger.Info("ready to send notification ...", slog.Any("config", nodeCfg)) diff --git a/internal/workflow/engine/executor_bizupload.go b/internal/workflow/engine/executor_bizupload.go index ab2c808f1..aa8a5eeff 100644 --- a/internal/workflow/engine/executor_bizupload.go +++ b/internal/workflow/engine/executor_bizupload.go @@ -10,6 +10,15 @@ import ( "github.com/certimate-go/certimate/internal/repository" ) +/** + * Result Variables: + * - node.skipped: boolean + * - certificate.validity: boolean + * - certificate.daysLeft: number + * + * Result Outputs: + * - ref: certificate: string + */ type bizUploadNodeExecutor struct { nodeExecutor @@ -18,7 +27,7 @@ type bizUploadNodeExecutor struct { } func (ne *bizUploadNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { - execRes := &NodeExecutionResult{} + execRes := newNodeExecutionResult(execCtx.Node) nodeCfg := execCtx.Node.Data.Config.AsBizUpload() ne.logger.Info("ready to upload certiticate ...", slog.Any("config", nodeCfg)) @@ -28,15 +37,16 @@ func (ne *bizUploadNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx if err != nil { return execRes, err } else if lastCertificate != nil { - execRes.AddVariable(execCtx.Node.Id, stateVarKeyCertificateValidity, time.Now().After(lastCertificate.ValidityNotAfter), "boolean") - execRes.AddVariable(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, int32(time.Until(lastCertificate.ValidityNotAfter).Hours()/24), "number") + execRes.AddOutput(stateIOTypeRef, "certificate", fmt.Sprintf("%s#%s", domain.CollectionNameCertificate, lastCertificate.Id), "string") + execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateValidity, time.Now().After(lastCertificate.ValidityNotAfter), "boolean") + execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, int32(time.Until(lastCertificate.ValidityNotAfter).Hours()/24), "number") } // 检测是否可以跳过本次执行 if skippable, reason := ne.checkCanSkip(execCtx, lastOutput, lastCertificate); skippable { ne.logger.Info(fmt.Sprintf("skip this uploading, because %s", reason)) - execRes.AddVariable(execCtx.Node.Id, stateVarKeyNodeSkipped, true, "boolean") + execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyNodeSkipped, true, "boolean") return execRes, nil } else if reason != "" { ne.logger.Info(fmt.Sprintf("re-upload, because %s", reason)) @@ -44,38 +54,26 @@ func (ne *bizUploadNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx ne.logger.Info("no found last uploaded certificate, begin to upload") } - // 生成证书实体 + // 保存证书实体 certificate := &domain.Certificate{ - Source: domain.CertificateSourceTypeUpload, + Source: domain.CertificateSourceTypeUpload, + WorkflowId: execCtx.WorkflowId, + WorkflowRunId: execCtx.RunId, + WorkflowNodeId: execCtx.Node.Id, } certificate.PopulateFromPEM(nodeCfg.Certificate, nodeCfg.PrivateKey) - - // 保存执行结果 - // TODO: 解耦 - output := &domain.WorkflowOutput{ - WorkflowId: execCtx.WorkflowId, - RunId: execCtx.RunId, - NodeId: execCtx.Node.Id, - NodeConfig: execCtx.Node.Data.Config, - Succeeded: true, - Outputs: []*domain.WorkflowOutputEntry{ - { - Name: "certificate", - Type: "certificate", - Value: certificate.Id, - ValueType: "string", - }, - }, - } - if _, err := ne.wfoutputRepo.SaveWithCertificate(execCtx.ctx, output, certificate); err != nil { - ne.logger.Warn("failed to save node output") + if certificate, err := ne.certificateRepo.Save(execCtx.ctx, certificate); err != nil { + ne.logger.Warn("failed to save certificate") return execRes, err + } else { + ne.logger.Info("certificate saved", slog.String("recordId", certificate.Id)) } - // 记录中间结果 - execRes.AddVariable(execCtx.Node.Id, stateVarKeyNodeSkipped, false, "boolean") - execRes.AddVariable(execCtx.Node.Id, stateVarKeyCertificateValidity, true, "boolean") - execRes.AddVariable(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, int32(time.Until(certificate.ValidityNotAfter).Hours()/24), "number") + // 节点输出 + execRes.AddOutputWithPersistent(stateIOTypeRef, "certificate", fmt.Sprintf("%s#%s", domain.CollectionNameCertificate, certificate.Id), "string") + execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyNodeSkipped, false, "boolean") + execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateValidity, true, "boolean") + execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, int32(time.Until(certificate.ValidityNotAfter).Hours()/24), "number") ne.logger.Info("uploading completed") return execRes, nil diff --git a/internal/workflow/engine/executor_condition.go b/internal/workflow/engine/executor_condition.go index 01aca9528..32bff15b1 100644 --- a/internal/workflow/engine/executor_condition.go +++ b/internal/workflow/engine/executor_condition.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "log/slog" - "strconv" "github.com/samber/lo" ) @@ -21,7 +20,7 @@ func (ne *conditionNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx engine = we } - execRes := &NodeExecutionResult{} + execRes := newNodeExecutionResult(execCtx.Node) errs := make([]error, 0) blocks := lo.Filter(execCtx.Node.Blocks, func(n *Node, _ int) bool { return n.Type == NodeTypeBranchBlock }) @@ -59,28 +58,19 @@ type branchBlockNodeExecutor struct { } func (ne *branchBlockNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { - execRes := &NodeExecutionResult{} + execRes := newNodeExecutionResult(execCtx.Node) nodeCfg := execCtx.Node.Data.Config.AsBranchBlock() if nodeCfg.Expression == nil { ne.logger.Info("enter this branch without any conditions") } else { - variables := lo.Reduce(execCtx.variables.All(), func(acc map[string]map[string]any, entry StateEntry, _ int) map[string]map[string]any { - if _, ok := acc[entry.Scope]; !ok { - acc[entry.Scope] = make(map[string]any) + variables := lo.Reduce(execCtx.variables.All(), func(acc map[string]map[string]any, state VariableState, _ int) map[string]map[string]any { + if _, ok := acc[state.Scope]; !ok { + acc[state.Scope] = make(map[string]any) } // 这里需要把所有值都转换为字符串形式,因为 Expression.Eval 仅支持字符串类型的值 - var value any - if entry.ValueType == "number" { - value = fmt.Sprintf("%d", entry.Value) - } else if entry.ValueType == "boolean" { - value = strconv.FormatBool(entry.Value.(bool)) - } else { - value = entry.Value - } - acc[entry.Scope][entry.Key] = value - + acc[state.Scope][state.Key] = state.ValueString() return acc }, make(map[string]map[string]any)) diff --git a/internal/workflow/engine/executor_end.go b/internal/workflow/engine/executor_end.go index ac440f432..1a9a02fb7 100644 --- a/internal/workflow/engine/executor_end.go +++ b/internal/workflow/engine/executor_end.go @@ -9,7 +9,7 @@ type endNodeExecutor struct { } func (e *endNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { - execRes := &NodeExecutionResult{} + execRes := newNodeExecutionResult(execCtx.Node) execRes.Interrupted = true return execRes, nil diff --git a/internal/workflow/engine/executor_start.go b/internal/workflow/engine/executor_start.go index e5db453f1..ab0ada84e 100644 --- a/internal/workflow/engine/executor_start.go +++ b/internal/workflow/engine/executor_start.go @@ -9,7 +9,7 @@ type startNodeExecutor struct { } func (e *startNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { - execRes := &NodeExecutionResult{} + execRes := newNodeExecutionResult(execCtx.Node) return execRes, nil } diff --git a/internal/workflow/engine/executor_trycatch.go b/internal/workflow/engine/executor_trycatch.go index be0753b6b..28d8844ec 100644 --- a/internal/workflow/engine/executor_trycatch.go +++ b/internal/workflow/engine/executor_trycatch.go @@ -20,7 +20,7 @@ func (ne *tryCatchNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExe engine = we } - execRes := &NodeExecutionResult{} + execRes := newNodeExecutionResult(execCtx.Node) tryErrs := make([]error, 0) tryBlocks := lo.Filter(execCtx.Node.Blocks, func(n *Node, _ int) bool { return n.Type == NodeTypeTryBlock }) @@ -87,7 +87,7 @@ func (ne *tryBlockNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExe engine = we } - execRes := &NodeExecutionResult{} + execRes := newNodeExecutionResult(execCtx.Node) if err := engine.executeBlocks(execCtx.Clone(), execCtx.Node.Blocks); err != nil { return execRes, err @@ -107,7 +107,7 @@ type catchBlockNodeExecutor struct { } func (ne *catchBlockNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) { - execRes := &NodeExecutionResult{} + execRes := newNodeExecutionResult(execCtx.Node) var engine *workflowEngine if we, ok := execCtx.engine.(*workflowEngine); !ok { diff --git a/internal/workflow/engine/services.go b/internal/workflow/engine/services.go index 14105abc5..a7c30e95b 100644 --- a/internal/workflow/engine/services.go +++ b/internal/workflow/engine/services.go @@ -7,7 +7,7 @@ import ( ) type certificateRepository interface { - GetByWorkflowIdAndNodeId(ctx context.Context, workflowId string, workflowNodeId string) (*domain.Certificate, error) + GetById(ctx context.Context, id string) (*domain.Certificate, error) GetByWorkflowRunIdAndNodeId(ctx context.Context, workflowRunId string, workflowNodeId string) (*domain.Certificate, error) Save(ctx context.Context, certificate *domain.Certificate) (*domain.Certificate, error) } @@ -19,7 +19,6 @@ type workflowRunRepository interface { type workflowOutputRepository interface { GetByNodeId(ctx context.Context, workflowNodeId string) (*domain.WorkflowOutput, error) Save(ctx context.Context, workflowOutput *domain.WorkflowOutput) (*domain.WorkflowOutput, error) - SaveWithCertificate(ctx context.Context, workflowOutput *domain.WorkflowOutput, certificate *domain.Certificate) (*domain.WorkflowOutput, error) } type settingsRepository interface { diff --git a/internal/workflow/engine/state.go b/internal/workflow/engine/state.go index 4d38f6a09..ee4e92816 100644 --- a/internal/workflow/engine/state.go +++ b/internal/workflow/engine/state.go @@ -1,135 +1,279 @@ package engine import ( + "fmt" "slices" + "strconv" "sync" ) -type StateEntry struct { +type VariableState struct { Scope string // 零值时表示全局的,否则表示指定节点的 - Type string // 仅表示输入输出有值,表示变量无值 Key string Value any - ValueType string `options:"string | number | boolean"` + ValueType string } -type StateManager interface { - All() []StateEntry +func (s VariableState) ValueString() string { + switch s.ValueType { + case "string": + return s.Value.(string) + case "number": + return fmt.Sprintf("%d", s.Value) + case "boolean": + return strconv.FormatBool(s.Value.(bool)) + default: + return fmt.Sprintf("%v", s.Value) + } +} + +type VariableManager interface { + All() []VariableState Erase() - Add(entry StateEntry) - Get(name string) (*StateEntry, bool) - GetScoped(scope string, name string) (*StateEntry, bool) - Take(name string) (*StateEntry, bool) - TakeScoped(scope string, name string) (*StateEntry, bool) - Remove(name string) bool - RemoveScoped(scope string, name string) bool + Add(entry VariableState) + Set(name string, value any, valueType string) + SetScoped(scope string, name string, value any, valueType string) + Get(name string) (*VariableState, bool) + GetScoped(scope string, key string) (*VariableState, bool) + Take(key string) (*VariableState, bool) + TakeScoped(scope string, key string) (*VariableState, bool) + Remove(key string) bool + RemoveScoped(scope string, key string) bool } -type stateManager struct { - entriesMtx sync.RWMutex - entries []StateEntry +type variableManager struct { + statesMtx sync.RWMutex + states []VariableState } -var _ StateManager = (*stateManager)(nil) +var _ VariableManager = (*variableManager)(nil) -func (m *stateManager) All() []StateEntry { - m.entriesMtx.RLock() - defer m.entriesMtx.RUnlock() +func (m *variableManager) All() []VariableState { + m.statesMtx.RLock() + defer m.statesMtx.RUnlock() - if m.entries == nil { - return make([]StateEntry, 0) + if m.states == nil { + return make([]VariableState, 0) } - return slices.Clone(m.entries) + return slices.Clone(m.states) } -func (m *stateManager) Erase() { - m.entriesMtx.Lock() - defer m.entriesMtx.Unlock() +func (m *variableManager) Erase() { + m.statesMtx.Lock() + defer m.statesMtx.Unlock() - m.entries = make([]StateEntry, 0) + m.states = make([]VariableState, 0) } -func (m *stateManager) Add(entry StateEntry) { - m.entriesMtx.Lock() - defer m.entriesMtx.Unlock() +func (m *variableManager) Add(state VariableState) { + m.statesMtx.Lock() + defer m.statesMtx.Unlock() - if m.entries == nil { - m.entries = make([]StateEntry, 0) + if m.states == nil { + m.states = make([]VariableState, 0) } - for i, item := range m.entries { - if item.Scope == entry.Scope && item.Key == entry.Key { - m.entries[i] = entry + for i, item := range m.states { + if item.Scope == state.Scope && item.Key == state.Key { + m.states[i] = state return } } - m.entries = append(m.entries, entry) + m.states = append(m.states, state) } -func (m *stateManager) Get(name string) (*StateEntry, bool) { - return m.GetScoped("", name) +func (m *variableManager) Set(key string, value any, valueType string) { + m.SetScoped("", key, value, valueType) } -func (m *stateManager) GetScoped(scope string, name string) (*StateEntry, bool) { - m.entriesMtx.RLock() - defer m.entriesMtx.RUnlock() +func (m *variableManager) SetScoped(scope string, key string, value any, valueType string) { + m.Add(VariableState{Scope: scope, Key: key, Value: value, ValueType: valueType}) +} - if m.entries == nil { +func (m *variableManager) Get(key string) (*VariableState, bool) { + return m.GetScoped("", key) +} + +func (m *variableManager) GetScoped(scope string, key string) (*VariableState, bool) { + m.statesMtx.RLock() + defer m.statesMtx.RUnlock() + + if m.states == nil { return nil, false } - for _, item := range m.entries { - if item.Scope == scope && item.Key == name { + for _, item := range m.states { + if item.Scope == scope && item.Key == key { return &item, true } } return nil, false } -func (m *stateManager) Take(name string) (*StateEntry, bool) { - return m.TakeScoped("", name) +func (m *variableManager) Take(key string) (*VariableState, bool) { + return m.TakeScoped("", key) } -func (m *stateManager) TakeScoped(scope, name string) (*StateEntry, bool) { - m.entriesMtx.Lock() - defer m.entriesMtx.Unlock() +func (m *variableManager) TakeScoped(scope string, key string) (*VariableState, bool) { + m.statesMtx.Lock() + defer m.statesMtx.Unlock() - if m.entries == nil { + if m.states == nil { return nil, false } - for i, item := range m.entries { - if item.Scope == scope && item.Key == name { - m.entries = slices.Delete(m.entries, i, i+1) + for i, item := range m.states { + if item.Scope == scope && item.Key == key { + m.states = slices.Delete(m.states, i, i+1) return &item, true } } return nil, false } -func (m *stateManager) Remove(name string) bool { - return m.RemoveScoped("", name) +func (m *variableManager) Remove(key string) bool { + return m.RemoveScoped("", key) } -func (m *stateManager) RemoveScoped(scope string, name string) bool { - _, ok := m.TakeScoped(scope, name) +func (m *variableManager) RemoveScoped(scope string, key string) bool { + _, ok := m.TakeScoped(scope, key) return ok } -func newStateManager() StateManager { - return &stateManager{ - entries: make([]StateEntry, 0), +func newVariableManager() VariableManager { + return &variableManager{ + states: make([]VariableState, 0), + } +} + +type InOutState struct { + NodeId string + Type string + Name string + Value any + ValueType string + Persistent bool +} + +func (s InOutState) ValueString() string { + switch s.ValueType { + case "string": + return s.Value.(string) + case "number": + return fmt.Sprintf("%d", s.Value) + case "boolean": + return strconv.FormatBool(s.Value.(bool)) + default: + return fmt.Sprintf("%v", s.Value) + } +} + +type InOutManager interface { + All() []InOutState + Erase() + + Add(state InOutState) + Set(nodeId string, stype string, name string, value any, valueType string, persistent bool) + Get(nodeId string, name string) (*InOutState, bool) + Take(nodeId string, name string) (*InOutState, bool) + Remove(nodeId string, name string) bool +} + +type inoutManager struct { + statesMtx sync.RWMutex + states []InOutState +} + +var _ InOutManager = (*inoutManager)(nil) + +func (m *inoutManager) All() []InOutState { + m.statesMtx.RLock() + defer m.statesMtx.RUnlock() + + if m.states == nil { + return make([]InOutState, 0) + } + + return slices.Clone(m.states) +} + +func (m *inoutManager) Erase() { + m.statesMtx.Lock() + defer m.statesMtx.Unlock() + + m.states = make([]InOutState, 0) +} + +func (m *inoutManager) Add(state InOutState) { + m.statesMtx.Lock() + defer m.statesMtx.Unlock() + + if m.states == nil { + m.states = make([]InOutState, 0) + } + + for i, item := range m.states { + if item.NodeId == state.NodeId && item.Name == state.Name { + m.states[i] = state + return + } + } + m.states = append(m.states, state) +} + +func (m *inoutManager) Set(nodeId string, stype string, name string, value any, valueType string, persistent bool) { + m.Add(InOutState{NodeId: nodeId, Type: stype, Name: name, Value: value, ValueType: valueType, Persistent: persistent}) +} + +func (m *inoutManager) Get(nodeId string, name string) (*InOutState, bool) { + m.statesMtx.RLock() + defer m.statesMtx.RUnlock() + + if m.states == nil { + return nil, false + } + + for _, item := range m.states { + if item.NodeId == nodeId && item.Name == name { + return &item, true + } + } + return nil, false +} + +func (m *inoutManager) Take(nodeId string, name string) (*InOutState, bool) { + m.statesMtx.Lock() + defer m.statesMtx.Unlock() + + if m.states == nil { + return nil, false + } + + for i, item := range m.states { + if item.NodeId == nodeId && item.Name == name { + m.states = slices.Delete(m.states, i, i+1) + return &item, true + } + } + return nil, false +} + +func (m *inoutManager) Remove(nodeId string, name string) bool { + _, ok := m.Take(nodeId, name) + return ok +} + +func newInOutManager() InOutManager { + return &inoutManager{ + states: make([]InOutState, 0), } } const ( - stateIOTypeCertificate = "certificate" -) - -const ( - stateIOKeyCertificate = "certificate" + stateIOTypeRef = "ref" + stateIOType = "recordRef" ) const ( diff --git a/migrations/1755187200_cm0.4.0_migrate.go b/migrations/1755187200_cm0.4.0_migrate.go index 483df9628..0a4e6f0e1 100644 --- a/migrations/1755187200_cm0.4.0_migrate.go +++ b/migrations/1755187200_cm0.4.0_migrate.go @@ -2,6 +2,7 @@ package migrations import ( "encoding/json" + "fmt" "regexp" "strings" @@ -129,8 +130,7 @@ func init() { // - rename field `expireAt` to `validityNotAfter` // - rename field `workflowId` to `workflowRef` // - rename field `workflowRunId` to `workflowRunRef` - // - rename field `workflowOutputId` to `workflowOutputRef` - // - rename field `workflowOutputId` to `workflowOutputRef` + // - rename field `workflowOutputId`(aka `workflowOutputRef`) { collection, err := app.FindCollectionByNameOrId("4szxr9x43tpj6np") if err != nil { @@ -213,21 +213,8 @@ func init() { return err } - if err := collection.Fields.AddMarshaledJSONAt(17, []byte(`{ - "cascadeDelete": false, - "collectionId": "bqnxb95f2cooowp", - "hidden": false, - "id": "2ohlr0yd", - "maxSelect": 1, - "minSelect": 0, - "name": "workflowOutputRef", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }`)); err != nil { - return err - } + collection.Fields.RemoveByName("workflowOutputId") + collection.Fields.RemoveByName("workflowOutputRef") if err := json.Unmarshal([]byte(`{ "indexes": [ @@ -1092,6 +1079,12 @@ func init() { delete(output, "label") delete(output, "required") delete(output, "valueSelector") + + if output["type"] == "certificate" { + output["type"] = "ref" + output["value"] = fmt.Sprintf("certificate#%s", output["value"]) + } + outputs[i] = output } else { continue From a23dd0b9e5af47e7f835d820696edb64fe1c06f5 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Thu, 21 Aug 2025 19:54:50 +0800 Subject: [PATCH 18/20] refactor: clean code --- internal/certificate/service.go | 59 ++++++++------- internal/certificate/service_deps.go | 19 +++++ internal/statistics/service.go | 4 -- internal/statistics/service_deps.go | 11 +++ internal/workflow/dispatcher/invoker.go | 88 ----------------------- internal/workflow/dispatcher/singleton.go | 1 + internal/workflow/service.go | 71 ++++++++---------- internal/workflow/service_deps.go | 25 +++++++ 8 files changed, 115 insertions(+), 163 deletions(-) create mode 100644 internal/certificate/service_deps.go create mode 100644 internal/statistics/service_deps.go create mode 100644 internal/workflow/service_deps.go diff --git a/internal/certificate/service.go b/internal/certificate/service.go index c844b17f9..b27219795 100644 --- a/internal/certificate/service.go +++ b/internal/certificate/service.go @@ -17,16 +17,6 @@ import ( xcert "github.com/certimate-go/certimate/pkg/utils/cert" ) -type certificateRepository interface { - ListExpireSoon(ctx context.Context) ([]*domain.Certificate, error) - GetById(ctx context.Context, id string) (*domain.Certificate, error) - DeleteWhere(ctx context.Context, exprs ...dbx.Expression) (int, error) -} - -type settingsRepository interface { - GetByName(ctx context.Context, name string) (*domain.Settings, error) -} - type CertificateService struct { certificateRepo certificateRepository settingsRepo settingsRepository @@ -41,27 +31,8 @@ func NewCertificateService(certificateRepo certificateRepository, settingsRepo s func (s *CertificateService) InitSchedule(ctx context.Context) error { // 每日清理过期证书 - app.GetScheduler().MustAdd("certificateExpiredCleanup", "0 0 * * *", func() { - settings, err := s.settingsRepo.GetByName(ctx, "persistence") - if err != nil { - app.GetLogger().Error("failed to get persistence settings", "err", err) - return - } - - persistenceSettings, _ := settings.UnmarshalContentAsPersistence() - if persistenceSettings != nil && persistenceSettings.ExpiredCertificatesMaxDaysRetention != 0 { - ret, err := s.certificateRepo.DeleteWhere( - context.Background(), - dbx.NewExp(fmt.Sprintf("validityNotAfter 0 { - app.GetLogger().Info(fmt.Sprintf("cleanup %d expired certificates", ret)) - } - } + app.GetScheduler().MustAdd("cleanupCertificateExpired", "0 0 * * *", func() { + s.cleanupExpiredCertificates(context.Background()) }) return nil @@ -218,3 +189,29 @@ func (s *CertificateService) ValidatePrivateKey(ctx context.Context, req *dtos.C IsValid: true, }, nil } + +func (s *CertificateService) cleanupExpiredCertificates(ctx context.Context) error { + settings, err := s.settingsRepo.GetByName(ctx, "persistence") + if err != nil { + app.GetLogger().Error("failed to get persistence settings", "err", err) + return err + } + + persistenceSettings, _ := settings.UnmarshalContentAsPersistence() + if persistenceSettings != nil && persistenceSettings.ExpiredCertificatesMaxDaysRetention != 0 { + ret, err := s.certificateRepo.DeleteWhere( + context.Background(), + dbx.NewExp(fmt.Sprintf("validityNotAfter 0 { + app.GetLogger().Info(fmt.Sprintf("cleanup %d expired certificates", ret)) + } + } + + return nil +} diff --git a/internal/certificate/service_deps.go b/internal/certificate/service_deps.go new file mode 100644 index 000000000..7b22dbbad --- /dev/null +++ b/internal/certificate/service_deps.go @@ -0,0 +1,19 @@ +package certificate + +import ( + "context" + + "github.com/pocketbase/dbx" + + "github.com/certimate-go/certimate/internal/domain" +) + +type certificateRepository interface { + ListExpireSoon(ctx context.Context) ([]*domain.Certificate, error) + GetById(ctx context.Context, id string) (*domain.Certificate, error) + DeleteWhere(ctx context.Context, exprs ...dbx.Expression) (int, error) +} + +type settingsRepository interface { + GetByName(ctx context.Context, name string) (*domain.Settings, error) +} diff --git a/internal/statistics/service.go b/internal/statistics/service.go index ea041a15a..b9d2b37ac 100644 --- a/internal/statistics/service.go +++ b/internal/statistics/service.go @@ -6,10 +6,6 @@ import ( "github.com/certimate-go/certimate/internal/domain" ) -type statisticsRepository interface { - Get(ctx context.Context) (*domain.Statistics, error) -} - type StatisticsService struct { statRepo statisticsRepository } diff --git a/internal/statistics/service_deps.go b/internal/statistics/service_deps.go new file mode 100644 index 000000000..2c53fa2d5 --- /dev/null +++ b/internal/statistics/service_deps.go @@ -0,0 +1,11 @@ +package statistics + +import ( + "context" + + "github.com/certimate-go/certimate/internal/domain" +) + +type statisticsRepository interface { + Get(ctx context.Context) (*domain.Statistics, error) +} diff --git a/internal/workflow/dispatcher/invoker.go b/internal/workflow/dispatcher/invoker.go index b85440fe9..d838b4996 100644 --- a/internal/workflow/dispatcher/invoker.go +++ b/internal/workflow/dispatcher/invoker.go @@ -85,91 +85,3 @@ func (w *workflowInvoker) Invoke(ctx context.Context) error { func (w *workflowInvoker) GetLogs() domain.WorkflowLogs { return w.logs } - -func (w *workflowInvoker) processNode(ctx context.Context, graph *domain.WorkflowGraph) error { - // current := graph - // for current != nil { - // select { - // case <-ctx.Done(): - // return ctx.Err() - // default: - // } - - // if current.Type == domain.WorkflowNodeTypeCondition || current.Type == domain.WorkflowNodeTypeTryCatch { - // for _, branch := range current.Branches { - // if err := w.processNode(ctx, &branch); err != nil { - // // 并行分支的某一分支发生错误时,忽略此错误,继续执行其他分支 - // if !(errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) { - // continue - // } - // return err - // } - // } - // } - - // var processor nodes.NodeProcessor - // var procErr error - // for { - // if current.Type != domain.WorkflowNodeTypeCondition && current.Type != domain.WorkflowNodeTypeTryCatch { - // processor, procErr = nodes.GetProcessor(current) - // if procErr != nil { - // panic(procErr) - // } - - // processor.SetLogger(slog.New(logging.NewHookHandler(&logging.HookHandlerOptions{ - // Level: slog.LevelDebug, - // WriteFunc: func(ctx context.Context, record *logging.Record) error { - // log := domain.WorkflowLog{} - // log.WorkflowId = w.workflowId - // log.RunId = w.runId - // log.NodeId = current.Id - // log.NodeName = current.Name - // log.Timestamp = record.Time.UnixMilli() - // log.Level = int32(record.Level) - // log.Message = record.Message - // log.Data = record.Data - // log.CreatedAt = record.Time - // if _, err := w.workflowLogRepo.Save(ctx, &log); err != nil { - // return err - // } - - // w.logs = append(w.logs, log) - // return nil - // }, - // }))) - - // procErr = processor.Process(ctx) - // if procErr != nil { - // if current.Type != domain.WorkflowNodeTypeBranchBlock { - // processor.GetLogger().Error(procErr.Error()) - // } - // break - // } - - // nodeOutputs := processor.GetOutputs() - // if len(nodeOutputs) > 0 { - // ctx = nodes.AddNodeOutput(ctx, current.Id, nodeOutputs) - // } - // } - - // break - // } - - // // TODO: 优化可读性 - // if procErr != nil && current.Type == domain.WorkflowNodeTypeBranchBlock { - // current = nil - // procErr = nil - // return nil - // } else if procErr != nil && current.Next != nil && current.Next.Type != domain.WorkflowNodeTypeTryCatch { - // return procErr - // } else if procErr != nil && current.Next != nil && current.Next.Type == domain.WorkflowNodeTypeTryCatch { - // current = w.getBranchByType(current.Next.Branches, domain.WorkflowNodeTypeCatchBlock) - // } else if procErr == nil && current.Next != nil && current.Next.Type == domain.WorkflowNodeTypeTryCatch { - // current = w.getBranchByType(current.Next.Branches, domain.WorkflowNodeTypeTryBlock) - // } else { - // current = current.Next - // } - // } - - return nil -} diff --git a/internal/workflow/dispatcher/singleton.go b/internal/workflow/dispatcher/singleton.go index d87b57b1d..8af0921da 100644 --- a/internal/workflow/dispatcher/singleton.go +++ b/internal/workflow/dispatcher/singleton.go @@ -15,6 +15,7 @@ type workflowRepository interface { type workflowRunRepository interface { GetById(ctx context.Context, id string) (*domain.WorkflowRun, error) + Save(ctx context.Context, workflowRun *domain.WorkflowRun) (*domain.WorkflowRun, error) SaveWithCascading(ctx context.Context, workflowRun *domain.WorkflowRun) (*domain.WorkflowRun, error) } diff --git a/internal/workflow/service.go b/internal/workflow/service.go index 9b476ea9c..62286aedc 100644 --- a/internal/workflow/service.go +++ b/internal/workflow/service.go @@ -14,22 +14,6 @@ import ( "github.com/certimate-go/certimate/internal/workflow/dispatcher" ) -type workflowRepository interface { - ListEnabledScheduled(ctx context.Context) ([]*domain.Workflow, error) - GetById(ctx context.Context, id string) (*domain.Workflow, error) - Save(ctx context.Context, workflow *domain.Workflow) (*domain.Workflow, error) -} - -type workflowRunRepository interface { - GetById(ctx context.Context, id string) (*domain.WorkflowRun, error) - SaveWithCascading(ctx context.Context, workflowRun *domain.WorkflowRun) (*domain.WorkflowRun, error) - DeleteWhere(ctx context.Context, exprs ...dbx.Expression) (int, error) -} - -type settingsRepository interface { - GetByName(ctx context.Context, name string) (*domain.Settings, error) -} - type WorkflowService struct { dispatcher *dispatcher.WorkflowDispatcher @@ -51,29 +35,8 @@ func NewWorkflowService(workflowRepo workflowRepository, workflowRunRepo workflo func (s *WorkflowService) InitSchedule(ctx context.Context) error { // 每日清理工作流执行历史 - app.GetScheduler().MustAdd("workflowHistoryRunsCleanup", "0 0 * * *", func() { - settings, err := s.settingsRepo.GetByName(ctx, "persistence") - if err != nil { - app.GetLogger().Error(fmt.Sprintf("failed to get persistence settings: %w", err)) - return - } - - persistenceSettings, _ := settings.UnmarshalContentAsPersistence() - if persistenceSettings != nil && persistenceSettings.WorkflowRunsMaxDaysRetention != 0 { - ret, err := s.workflowRunRepo.DeleteWhere( - context.Background(), - dbx.NewExp(fmt.Sprintf("status!='%s'", string(domain.WorkflowRunStatusTypePending))), - dbx.NewExp(fmt.Sprintf("status!='%s'", string(domain.WorkflowRunStatusTypeProcessing))), - dbx.NewExp(fmt.Sprintf("endedAt 0 { - app.GetLogger().Info(fmt.Sprintf("cleanup %d workflow history runs", ret)) - } - } + app.GetScheduler().MustAdd("cleanupWorkflowHistoryRuns", "0 0 * * *", func() { + s.cleanupHistoryRuns(context.Background()) }) // 工作流后台任务 @@ -87,7 +50,7 @@ func (s *WorkflowService) InitSchedule(ctx context.Context) error { var errs []error err := app.GetScheduler().Add(fmt.Sprintf("workflow#%s", workflow.Id), workflow.TriggerCron, func() { - s.StartRun(ctx, &dtos.WorkflowStartRunReq{ + s.StartRun(context.Background(), &dtos.WorkflowStartRunReq{ WorkflowId: workflow.Id, RunTrigger: domain.WorkflowTriggerTypeScheduled, }) @@ -161,3 +124,31 @@ func (s *WorkflowService) CancelRun(ctx context.Context, req *dtos.WorkflowCance func (s *WorkflowService) Shutdown(ctx context.Context) { s.dispatcher.Shutdown() } + +func (s *WorkflowService) cleanupHistoryRuns(ctx context.Context) error { + settings, err := s.settingsRepo.GetByName(ctx, "persistence") + if err != nil { + app.GetLogger().Error(fmt.Sprintf("failed to get persistence settings: %w", err)) + return err + } + + persistenceSettings, _ := settings.UnmarshalContentAsPersistence() + if persistenceSettings != nil && persistenceSettings.WorkflowRunsMaxDaysRetention != 0 { + ret, err := s.workflowRunRepo.DeleteWhere( + context.Background(), + dbx.NewExp(fmt.Sprintf("status!='%s'", string(domain.WorkflowRunStatusTypePending))), + dbx.NewExp(fmt.Sprintf("status!='%s'", string(domain.WorkflowRunStatusTypeProcessing))), + dbx.NewExp(fmt.Sprintf("endedAt 0 { + app.GetLogger().Info(fmt.Sprintf("cleanup %d workflow history runs", ret)) + } + } + + return nil +} diff --git a/internal/workflow/service_deps.go b/internal/workflow/service_deps.go new file mode 100644 index 000000000..b95eda059 --- /dev/null +++ b/internal/workflow/service_deps.go @@ -0,0 +1,25 @@ +package workflow + +import ( + "context" + + "github.com/pocketbase/dbx" + + "github.com/certimate-go/certimate/internal/domain" +) + +type workflowRepository interface { + ListEnabledScheduled(ctx context.Context) ([]*domain.Workflow, error) + GetById(ctx context.Context, id string) (*domain.Workflow, error) + Save(ctx context.Context, workflow *domain.Workflow) (*domain.Workflow, error) +} + +type workflowRunRepository interface { + GetById(ctx context.Context, id string) (*domain.WorkflowRun, error) + SaveWithCascading(ctx context.Context, workflowRun *domain.WorkflowRun) (*domain.WorkflowRun, error) + DeleteWhere(ctx context.Context, exprs ...dbx.Expression) (int, error) +} + +type settingsRepository interface { + GetByName(ctx context.Context, name string) (*domain.Settings, error) +} From 46ea0f2555ef14480a608bbc1174b31d5fdf1cfc Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Fri, 22 Aug 2025 11:48:00 +0800 Subject: [PATCH 19/20] refactor: re-impl workflow dispatcher --- internal/certificate/service.go | 5 +- internal/domain/dtos/workflow.go | 6 + internal/repository/workflow_run.go | 3 + internal/rest/handlers/certificate.go | 21 +- internal/rest/handlers/statistics.go | 7 +- internal/rest/handlers/workflow.go | 14 +- internal/scheduler/scheduler.go | 6 +- internal/workflow/dispatcher/deps.go | 22 + internal/workflow/dispatcher/dispatcher.go | 446 +++++++++--------- internal/workflow/dispatcher/invoker.go | 87 ---- internal/workflow/dispatcher/singleton.go | 30 +- internal/workflow/dispatcher/task.go | 13 + .../workflow/engine/{services.go => deps.go} | 0 internal/workflow/service.go | 62 +-- internal/workflow/service_deps.go | 1 + main.go | 2 +- .../components/workflow/designer/Designer.tsx | 8 + .../components/workflow/designer/Toolbar.tsx | 4 +- ui/src/i18n/locales/en/nls.workflow.runs.json | 6 +- ui/src/pages/ConsoleLayout.tsx | 2 +- ui/src/pages/workflows/WorkflowDetailRuns.tsx | 4 +- 21 files changed, 358 insertions(+), 391 deletions(-) create mode 100644 internal/workflow/dispatcher/deps.go delete mode 100644 internal/workflow/dispatcher/invoker.go create mode 100644 internal/workflow/dispatcher/task.go rename internal/workflow/engine/{services.go => deps.go} (100%) diff --git a/internal/certificate/service.go b/internal/certificate/service.go index b27219795..6656ba5fd 100644 --- a/internal/certificate/service.go +++ b/internal/certificate/service.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "fmt" + "log/slog" "strings" "time" @@ -193,7 +194,7 @@ func (s *CertificateService) ValidatePrivateKey(ctx context.Context, req *dtos.C func (s *CertificateService) cleanupExpiredCertificates(ctx context.Context) error { settings, err := s.settingsRepo.GetByName(ctx, "persistence") if err != nil { - app.GetLogger().Error("failed to get persistence settings", "err", err) + app.GetLogger().Error("failed to get persistence settings", slog.Any("error", err)) return err } @@ -204,7 +205,7 @@ func (s *CertificateService) cleanupExpiredCertificates(ctx context.Context) err dbx.NewExp(fmt.Sprintf("validityNotAfter( }), ], + onInit: (ctx) => { + const maybeMobile = ["android", "ios", "iphone", "ipad", "micromessenger"].some((s) => navigator.userAgent.includes(s)); + if (maybeMobile) { + ctx.playground.editorState.changeState(EditorState.STATE_MOUSE_FRIENDLY_SELECT.id); + } + }, + onAllLayersRendered: (ctx) => { rendered.current = true; diff --git a/ui/src/components/workflow/designer/Toolbar.tsx b/ui/src/components/workflow/designer/Toolbar.tsx index 1479a9cff..8759b0c86 100644 --- a/ui/src/components/workflow/designer/Toolbar.tsx +++ b/ui/src/components/workflow/designer/Toolbar.tsx @@ -29,9 +29,9 @@ const Toolbar = ({ className, style }: ToolbarProps) => { return () => d.dispose(); }, [playground]); - const [isMinimapVisible, setIsMinimapVisible] = useState(window.screen.availWidth >= 1024); + const [isMinimapVisible, setIsMinimapVisible] = useState(() => window.screen.availWidth >= 1024); - const [isMouseFriendly, setIsMouseFriendly] = useState(playground.editorState.is(EditorState.STATE_MOUSE_FRIENDLY_SELECT.id)); + const [isMouseFriendly, setIsMouseFriendly] = useState(() => playground.editorState.is(EditorState.STATE_MOUSE_FRIENDLY_SELECT.id)); const handleToggleLayout = useCallback(() => { if (tools.isVertical) { diff --git a/ui/src/i18n/locales/en/nls.workflow.runs.json b/ui/src/i18n/locales/en/nls.workflow.runs.json index f2da6d123..525a68079 100644 --- a/ui/src/i18n/locales/en/nls.workflow.runs.json +++ b/ui/src/i18n/locales/en/nls.workflow.runs.json @@ -1,9 +1,9 @@ { "workflow_run.action.view.menu": "View details", - "workflow_run.action.cancel.menu": "Cancel workflow run", - "workflow_run.action.cancel.modal.title": "Cancel run", + "workflow_run.action.cancel.menu": "Cancel run", + "workflow_run.action.cancel.modal.title": "Cancel workflow run", "workflow_run.action.cancel.modal.content": "Are you sure to cancel this run?", - "workflow_run.action.delete.menu": "Delete workflow run", + "workflow_run.action.delete.menu": "Delete run", "workflow_run.action.delete.modal.title": "Delete \"{{name}}\"", "workflow_run.action.delete.modal.content": "Are you sure want to delete this workflow run?
This action cannot be undone.", "workflow_run.action.batch_delete.modal.title": "Delete workflow runs", diff --git a/ui/src/pages/ConsoleLayout.tsx b/ui/src/pages/ConsoleLayout.tsx index f9676b2ba..c2964657a 100644 --- a/ui/src/pages/ConsoleLayout.tsx +++ b/ui/src/pages/ConsoleLayout.tsx @@ -107,7 +107,7 @@ const ConsoleLayout = () => {