diff --git a/internal/domain/workflow.go b/internal/domain/workflow.go index 2190ba6d..8d342fe2 100644 --- a/internal/domain/workflow.go +++ b/internal/domain/workflow.go @@ -37,8 +37,8 @@ type WorkflowNode struct { Name string `json:"name"` Next *WorkflowNode `json:"next"` Config map[string]any `json:"config"` - Input []WorkflowNodeIo `json:"input"` - Output []WorkflowNodeIo `json:"output"` + Input []WorkflowNodeIO `json:"input"` + Output []WorkflowNodeIO `json:"output"` Validated bool `json:"validated"` Type string `json:"type"` @@ -76,16 +76,16 @@ func (n *WorkflowNode) GetConfigInt64(key string) int64 { return 0 } -type WorkflowNodeIo struct { +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"` + ValueSelector WorkflowNodeIOValueSelector `json:"valueSelector"` } -type WorkflowNodeIoValueSelector struct { +type WorkflowNodeIOValueSelector struct { Id string `json:"id"` Name string `json:"name"` } diff --git a/internal/domain/workflow_output.go b/internal/domain/workflow_output.go index 5ae09205..7ce0a005 100644 --- a/internal/domain/workflow_output.go +++ b/internal/domain/workflow_output.go @@ -7,6 +7,6 @@ type WorkflowOutput struct { Workflow string `json:"workflow"` NodeId string `json:"nodeId"` Node *WorkflowNode `json:"node"` - Output []WorkflowNodeIo `json:"output"` + Output []WorkflowNodeIO `json:"output"` Succeed bool `json:"succeed"` } diff --git a/internal/repository/workflow_output.go b/internal/repository/workflow_output.go index a5159a2c..cf99d82f 100644 --- a/internal/repository/workflow_output.go +++ b/internal/repository/workflow_output.go @@ -35,7 +35,7 @@ func (w *WorkflowOutputRepository) Get(ctx context.Context, nodeId string) (*dom return nil, errors.New("failed to unmarshal node") } - output := make([]domain.WorkflowNodeIo, 0) + output := make([]domain.WorkflowNodeIO, 0) if err := record.UnmarshalJSONField("output", &output); err != nil { return nil, errors.New("failed to unmarshal output") } diff --git a/ui/public/imgs/workflow/tpl-blank.png b/ui/public/imgs/workflow/tpl-blank.png new file mode 100644 index 00000000..8f683ce6 Binary files /dev/null and b/ui/public/imgs/workflow/tpl-blank.png differ diff --git a/ui/public/imgs/workflow/tpl-standard.png b/ui/public/imgs/workflow/tpl-standard.png new file mode 100644 index 00000000..46698a87 Binary files /dev/null and b/ui/public/imgs/workflow/tpl-standard.png differ diff --git a/ui/src/components/core/Version.tsx b/ui/src/components/core/Version.tsx index 71fff15e..4832cce8 100644 --- a/ui/src/components/core/Version.tsx +++ b/ui/src/components/core/Version.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { BookOutlined as BookOutlinedIcon } from "@ant-design/icons"; +import { ReadOutlined as ReadOutlinedIcon } from "@ant-design/icons"; import { Divider, Space, Typography } from "antd"; import { version } from "@/domain/version"; @@ -16,7 +16,7 @@ const Version = ({ className, style }: VersionProps) => {
- + {t("common.menu.document")}
diff --git a/ui/src/components/workflow/AddNode.tsx b/ui/src/components/workflow/AddNode.tsx index 758e9b3a..b5f96343 100644 --- a/ui/src/components/workflow/AddNode.tsx +++ b/ui/src/components/workflow/AddNode.tsx @@ -1,7 +1,7 @@ import { PlusOutlined as PlusOutlinedIcon } from "@ant-design/icons"; import { Dropdown } from "antd"; -import { newWorkflowNode, workflowNodeDropdownList, type WorkflowNodeType } from "@/domain/workflow"; +import { type WorkflowNodeType, newNode, workflowNodeDropdownList } from "@/domain/workflow"; import { useZustandShallowSelector } from "@/hooks"; import { useWorkflowStore } from "@/stores/workflow"; @@ -12,7 +12,7 @@ const AddNode = ({ data }: NodeProps | BrandNodeProps) => { const { addNode } = useWorkflowStore(useZustandShallowSelector(["addNode"])); const handleTypeSelected = (type: WorkflowNodeType, provider?: string) => { - const node = newWorkflowNode(type, { + const node = newNode(type, { providerType: provider, }); diff --git a/ui/src/components/workflow/DropdownMenuItemIcon.tsx b/ui/src/components/workflow/DropdownMenuItemIcon.tsx index 36b2d8d0..67fa4e21 100644 --- a/ui/src/components/workflow/DropdownMenuItemIcon.tsx +++ b/ui/src/components/workflow/DropdownMenuItemIcon.tsx @@ -1,11 +1,18 @@ -import { CloudUpload, GitFork, Megaphone, NotebookPen } from "lucide-react"; +import { + CloudUploadOutlined as CloudUploadOutlinedIcon, + SendOutlined as SendOutlinedIcon, + SisternodeOutlined as SisternodeOutlinedIcon, + SolutionOutlined as SolutionOutlinedIcon, +} from "@ant-design/icons"; +import { Avatar } from "antd"; + import { type WorkflowNodeDropdwonItemIcon, WorkflowNodeDropdwonItemIconType } from "@/domain/workflow"; const icons = new Map([ - ["NotebookPen", ], - ["CloudUpload", ], - ["GitFork", ], - ["Megaphone", ], + ["ApplyNodeIcon", ], + ["DeployNodeIcon", ], + ["BranchNodeIcon", ], + ["NotifyNodeIcon", ], ]); const DropdownMenuItemIcon = ({ type, name }: WorkflowNodeDropdwonItemIcon) => { @@ -13,7 +20,7 @@ const DropdownMenuItemIcon = ({ type, name }: WorkflowNodeDropdwonItemIcon) => { if (type === WorkflowNodeDropdwonItemIconType.Icon) { return icons.get(name); } else { - return ; + return ; } }; diff --git a/ui/src/domain/certificate.ts b/ui/src/domain/certificate.ts index 796b5429..9a399bdb 100644 --- a/ui/src/domain/certificate.ts +++ b/ui/src/domain/certificate.ts @@ -8,7 +8,7 @@ export interface CertificateModel extends BaseModel { certUrl: string; certStableUrl: string; output: string; - expireAt: string; + expireAt: ISO8601String; workflow: string; nodeId: string; expand: { diff --git a/ui/src/domain/workflow.ts b/ui/src/domain/workflow.ts index 82b68ea6..1633b60d 100644 --- a/ui/src/domain/workflow.ts +++ b/ui/src/domain/workflow.ts @@ -1,16 +1,10 @@ +import dayjs from "dayjs"; import { produce } from "immer"; import { nanoid } from "nanoid"; import i18n from "@/i18n"; import { deployProvidersMap } from "./provider"; -export type WorkflowOutput = { - time: string; - title: string; - content: string; - error: string; -}; - export interface WorkflowModel extends BaseModel { name: string; description?: string; @@ -22,6 +16,7 @@ export interface WorkflowModel extends BaseModel { hasDraft?: boolean; } +// #region Node export enum WorkflowNodeType { Start = "start", End = "end", @@ -33,7 +28,7 @@ export enum WorkflowNodeType { Custom = "custom", } -export const workflowNodeTypeDefaultName: Map = new Map([ +const workflowNodeTypeDefaultNames: Map = new Map([ [WorkflowNodeType.Start, i18n.t("workflow_node.start.label")], [WorkflowNodeType.End, i18n.t("workflow_node.end.label")], [WorkflowNodeType.Branch, i18n.t("workflow_node.branch.label")], @@ -44,21 +39,7 @@ export const workflowNodeTypeDefaultName: Map = new Ma [WorkflowNodeType.Custom, i18n.t("workflow_node.custom.title")], ]); -export type WorkflowNodeIo = { - name: string; - type: string; - required: boolean; - label: string; - value?: string; - valueSelector?: WorkflowNodeIoValueSelector; -}; - -export type WorkflowNodeIoValueSelector = { - id: string; - name: string; -}; - -export const workflowNodeTypeDefaultInput: Map = new Map([ +const workflowNodeTypeDefaultInputs: Map = new Map([ [WorkflowNodeType.Apply, []], [ WorkflowNodeType.Deploy, @@ -74,7 +55,7 @@ export const workflowNodeTypeDefaultInput: Map = new Map([ +const workflowNodeTypeDefaultOutputs: Map = new Map([ [ WorkflowNodeType.Apply, [ @@ -90,88 +71,122 @@ export const workflowNodeTypeDefaultOutput: Map; - export type WorkflowNode = { id: string; name: string; type: WorkflowNodeType; - validated?: boolean; - input?: WorkflowNodeIo[]; - config?: WorkflowNodeConfig; - output?: WorkflowNodeIo[]; + config?: Record; + input?: WorkflowNodeIO[]; + output?: WorkflowNodeIO[]; + + next?: WorkflowNode | WorkflowBranchNode; + branches?: WorkflowNode[]; + + validated?: boolean; +}; + +/** + * @deprecated + */ +export type WorkflowBranchNode = { + id: string; + name: string; + type: WorkflowNodeType.Branch; + + branches: WorkflowNode[]; next?: WorkflowNode | WorkflowBranchNode; }; -type NewWorkflowNodeOptions = { +export type WorkflowNodeIO = { + name: string; + type: string; + required: boolean; + label: string; + value?: string; + valueSelector?: WorkflowNodeIOValueSelector; +}; + +export type WorkflowNodeIOValueSelector = { + id: string; + name: string; +}; +// #endregion + +type InitWorkflowOptions = { + template?: "standard"; +}; + +export const initWorkflow = (options: InitWorkflowOptions = {}): WorkflowModel => { + const root = newNode(WorkflowNodeType.Start, {}) as WorkflowNode; + root.config = { executionMethod: "manual" }; + + if (options.template === "standard") { + let temp = root; + temp.next = newNode(WorkflowNodeType.Apply, {}); + + temp = temp.next; + temp.next = newNode(WorkflowNodeType.Deploy, {}); + + temp = temp.next; + temp.next = newNode(WorkflowNodeType.Notify, {}); + } + + return { + id: null!, + name: `MyWorkflow-${dayjs().format("YYYYMMDDHHmmss")}`, + type: root.config!.executionMethod as string, + crontab: root.config!.crontab as string, + enabled: false, + draft: root, + hasDraft: true, + created: new Date().toISOString(), + updated: new Date().toISOString(), + }; +}; + +type NewNodeOptions = { branchIndex?: number; providerType?: string; }; -export const initWorkflow = (): WorkflowModel => { - // 开始节点 - const rs = newWorkflowNode(WorkflowNodeType.Start, {}); - let root = rs; +export const newNode = (nodeType: WorkflowNodeType, options: NewNodeOptions): WorkflowNode | WorkflowBranchNode => { + const nodeTypeName = workflowNodeTypeDefaultNames.get(nodeType) || ""; + const nodeName = options.branchIndex != null ? `${nodeTypeName} ${options.branchIndex + 1}` : nodeTypeName; - // 申请节点 - root.next = newWorkflowNode(WorkflowNodeType.Apply, {}); - root = root.next; - - // 部署节点 - root.next = newWorkflowNode(WorkflowNodeType.Deploy, {}); - root = root.next; - - // 通知节点 - root.next = newWorkflowNode(WorkflowNodeType.Notify, {}); - - return { - id: "", - name: i18n.t("workflow.props.name.default"), - type: "auto", - crontab: "0 0 * * *", - enabled: false, - draft: rs, - created: new Date().toUTCString(), - updated: new Date().toUTCString(), - }; -}; - -export const newWorkflowNode = (type: WorkflowNodeType, options: NewWorkflowNodeOptions): WorkflowNode | WorkflowBranchNode => { - const id = nanoid(); - const typeName = workflowNodeTypeDefaultName.get(type) || ""; - const name = options.branchIndex !== undefined ? `${typeName} ${options.branchIndex + 1}` : typeName; - - let rs: WorkflowNode | WorkflowBranchNode = { - id, - name, - type, + const node: WorkflowNode | WorkflowBranchNode = { + id: nanoid(), + name: nodeName, + type: nodeType, }; - if (type === WorkflowNodeType.Apply || type === WorkflowNodeType.Deploy) { - rs = { - ...rs, - config: { - providerType: options.providerType, - }, - input: workflowNodeTypeDefaultInput.get(type), - output: workflowNodeTypeDefaultOutput.get(type), - }; + switch (nodeType) { + case WorkflowNodeType.Apply: + case WorkflowNodeType.Deploy: + { + node.config = { + providerType: options.providerType, + }; + node.input = workflowNodeTypeDefaultInputs.get(nodeType); + node.output = 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; } - if (type == WorkflowNodeType.Condition) { - rs.validated = true; - } - - if (type === WorkflowNodeType.Branch) { - rs = { - ...rs, - branches: [newWorkflowNode(WorkflowNodeType.Condition, { branchIndex: 0 }), newWorkflowNode(WorkflowNodeType.Condition, { branchIndex: 1 })], - }; - } - - return rs; + return node; }; export const isWorkflowBranchNode = (node: WorkflowNode | WorkflowBranchNode): node is WorkflowBranchNode => { @@ -226,7 +241,7 @@ export const addBranch = (node: WorkflowNode | WorkflowBranchNode, branchNodeId: return draft; } current.branches.push( - newWorkflowNode(WorkflowNodeType.Condition, { + newNode(WorkflowNodeType.Condition, { branchIndex: current.branches.length, }) ); @@ -340,21 +355,24 @@ export const getWorkflowOutputBeforeId = (node: WorkflowNode | WorkflowBranchNod return output; }; -export const isAllNodesValidated = (node: WorkflowNode | WorkflowBranchNode): boolean => { +export const isAllNodesValidated = (node: WorkflowNode): boolean => { let current = node as typeof node | undefined; while (current) { - if (!isWorkflowBranchNode(current) && !current.validated) { - return false; - } - if (isWorkflowBranchNode(current)) { - for (const branch of current.branches) { + if (current.type === WorkflowNodeType.Branch) { + for (const branch of current.branches!) { if (!isAllNodesValidated(branch)) { return false; } } + } else { + if (!current.validated) { + return false; + } } + current = current.next; } + return true; }; @@ -372,14 +390,9 @@ export const getExecuteMethod = (node: WorkflowNode): { type: string; crontab: s } }; -export type WorkflowBranchNode = { - id: string; - name: string; - type: WorkflowNodeType; - branches: WorkflowNode[]; - next?: WorkflowNode | WorkflowBranchNode; -}; - +/** + * @deprecated + */ type WorkflowNodeDropdwonItem = { type: WorkflowNodeType; providerType?: string; @@ -389,16 +402,25 @@ type WorkflowNodeDropdwonItem = { children?: WorkflowNodeDropdwonItem[]; }; +/** + * @deprecated + */ export enum WorkflowNodeDropdwonItemIconType { Icon, Provider, } +/** + * @deprecated + */ export type WorkflowNodeDropdwonItemIcon = { type: WorkflowNodeDropdwonItemIconType; name: string; }; +/** + * @deprecated + */ const workflowNodeDropdownDeployList: WorkflowNodeDropdwonItem[] = Array.from(deployProvidersMap.values()).map((item) => { return { type: WorkflowNodeType.Apply, @@ -412,41 +434,44 @@ const workflowNodeDropdownDeployList: WorkflowNodeDropdwonItem[] = Array.from(de }; }); +/** + * @deprecated + */ export const workflowNodeDropdownList: WorkflowNodeDropdwonItem[] = [ { type: WorkflowNodeType.Apply, - name: workflowNodeTypeDefaultName.get(WorkflowNodeType.Apply) ?? "", + name: workflowNodeTypeDefaultNames.get(WorkflowNodeType.Apply) ?? "", icon: { type: WorkflowNodeDropdwonItemIconType.Icon, - name: "NotebookPen", + name: "ApplyNodeIcon", }, leaf: true, }, { type: WorkflowNodeType.Deploy, - name: workflowNodeTypeDefaultName.get(WorkflowNodeType.Deploy) ?? "", + name: workflowNodeTypeDefaultNames.get(WorkflowNodeType.Deploy) ?? "", icon: { type: WorkflowNodeDropdwonItemIconType.Icon, - name: "CloudUpload", + name: "DeployNodeIcon", }, children: workflowNodeDropdownDeployList, }, { type: WorkflowNodeType.Branch, - name: workflowNodeTypeDefaultName.get(WorkflowNodeType.Branch) ?? "", + name: workflowNodeTypeDefaultNames.get(WorkflowNodeType.Branch) ?? "", leaf: true, icon: { type: WorkflowNodeDropdwonItemIconType.Icon, - name: "GitFork", + name: "BranchNodeIcon", }, }, { type: WorkflowNodeType.Notify, - name: workflowNodeTypeDefaultName.get(WorkflowNodeType.Notify) ?? "", + name: workflowNodeTypeDefaultNames.get(WorkflowNodeType.Notify) ?? "", leaf: true, icon: { type: WorkflowNodeDropdwonItemIconType.Icon, - name: "Megaphone", + name: "NotifyNodeIcon", }, }, ]; diff --git a/ui/src/domain/workflowRun.ts b/ui/src/domain/workflowRun.ts index f1c11411..045acabf 100644 --- a/ui/src/domain/workflowRun.ts +++ b/ui/src/domain/workflowRun.ts @@ -1,5 +1,3 @@ -import { type WorkflowOutput } from "./workflow"; - export interface WorkflowRunModel extends BaseModel { workflow: string; log: WorkflowRunLog[]; @@ -10,5 +8,12 @@ export interface WorkflowRunModel extends BaseModel { export type WorkflowRunLog = { nodeName: string; error: string; - outputs: WorkflowOutput[]; + outputs: WorkflowRunLogOutput[]; +}; + +export type WorkflowRunLogOutput = { + time: ISO8601String; + title: string; + content: string; + error: string; }; diff --git a/ui/src/global.d.ts b/ui/src/global.d.ts index 07951477..9c4f16f7 100644 --- a/ui/src/global.d.ts +++ b/ui/src/global.d.ts @@ -1,6 +1,8 @@ import { type BaseModel as PbBaseModel } from "pocketbase"; declare global { + declare type ISO8601String = string; + declare interface BaseModel extends PbBaseModel { created: ISO8601String; updated: ISO8601String; @@ -10,8 +12,6 @@ declare global { declare type MaybeModelRecord = T | Omit; declare type MaybeModelRecordWithId = T | Pick; - - declare type ISO8601String = string; } export {}; diff --git a/ui/src/i18n/index.ts b/ui/src/i18n/index.ts index fc212fbc..096453c3 100644 --- a/ui/src/i18n/index.ts +++ b/ui/src/i18n/index.ts @@ -3,7 +3,7 @@ import { initReactI18next } from "react-i18next"; import i18n from "i18next"; import LanguageDetector from "i18next-browser-languagedetector"; -import resources, { LOCALE_ZH_NAME, LOCALE_EN_NAME } from "./locales"; +import resources, { LOCALE_EN_NAME, LOCALE_ZH_NAME } from "./locales"; i18n .use(LanguageDetector) diff --git a/ui/src/i18n/locales/en/nls.common.json b/ui/src/i18n/locales/en/nls.common.json index 1148b5a4..a99047cf 100644 --- a/ui/src/i18n/locales/en/nls.common.json +++ b/ui/src/i18n/locales/en/nls.common.json @@ -3,9 +3,8 @@ "common.button.cancel": "Cancel", "common.button.copy": "Copy", "common.button.delete": "Delete", - "common.button.disable": "Disable", "common.button.edit": "Edit", - "common.button.enable": "Enable", + "common.button.more": "More", "common.button.ok": "Ok", "common.button.reset": "Reset", "common.button.save": "Save", diff --git a/ui/src/i18n/locales/en/nls.workflow.json b/ui/src/i18n/locales/en/nls.workflow.json index 0a60e7ba..1b1aec8e 100644 --- a/ui/src/i18n/locales/en/nls.workflow.json +++ b/ui/src/i18n/locales/en/nls.workflow.json @@ -7,14 +7,9 @@ "workflow.action.edit": "Edit workflow", "workflow.action.delete": "Delete workflow", "workflow.action.delete.confirm": "Are you sure to delete this workflow?", - "workflow.action.discard": "Discard changes", - "workflow.action.discard.confirm": "Are you sure to discard your changes?", - "workflow.action.release": "Release", - "workflow.action.release.confirm": "Are you sure to release your changes?", - "workflow.action.release.failed.uncompleted": "Please complete the orchestration first", - "workflow.action.run": "Run", - "workflow.action.run.confirm": "There are unreleased changes, are you sure to run this workflow based on the latest released version?", + "workflow.action.enable": "Enable", "workflow.action.enable.failed.uncompleted": "Please complete the orchestration and publish the changes first", + "workflow.action.disable": "Disable", "workflow.props.name": "Name", "workflow.props.description": "Description", @@ -28,14 +23,28 @@ "workflow.props.created_at": "Created at", "workflow.props.updated_at": "Updated at", - "workflow.detail.orchestration.tab": "Orchestration", - "workflow.detail.runs.tab": "History runs", + "workflow.new.title": "Create Workflow", + "workflow.new.subtitle": "Apply, deploy and notify with Workflows", + "workflow.new.templates.title": "Choose a Workflow Template", + "workflow.new.templates.template.standard.title": "Standard template", + "workflow.new.templates.template.standard.description": "A standard operating procedure that includes application, deployment, and notification steps.", + "workflow.new.templates.template.blank.title": "Blank template", + "workflow.new.templates.template.blank.description": "Customize all the contents of the workflow from the beginning.", "workflow.detail.baseinfo.modal.title": "Workflow base information", "workflow.detail.baseinfo.form.name.label": "Name", "workflow.detail.baseinfo.form.name.placeholder": "Please enter name", "workflow.detail.baseinfo.form.description.label": "Description", "workflow.detail.baseinfo.form.description.placeholder": "Please enter description", + "workflow.detail.orchestration.tab": "Orchestration", + "workflow.detail.orchestration.action.discard": "Discard changes", + "workflow.detail.orchestration.action.discard.confirm": "Are you sure to discard your changes?", + "workflow.detail.orchestration.action.release": "Release", + "workflow.detail.orchestration.action.release.confirm": "Are you sure to release your changes?", + "workflow.detail.orchestration.action.release.failed.uncompleted": "Please complete the orchestration first", + "workflow.detail.orchestration.action.run": "Run", + "workflow.detail.orchestration.action.run.confirm": "There are unreleased changes, are you sure to run this workflow based on the latest released version?", + "workflow.detail.runs.tab": "History runs", "workflow.common.certificate.label": "Certificate", "workflow.node.setting.label": "Setting Node", diff --git a/ui/src/i18n/locales/zh/nls.common.json b/ui/src/i18n/locales/zh/nls.common.json index 8d3fc779..468c61b3 100644 --- a/ui/src/i18n/locales/zh/nls.common.json +++ b/ui/src/i18n/locales/zh/nls.common.json @@ -3,9 +3,8 @@ "common.button.cancel": "取消", "common.button.copy": "复制", "common.button.delete": "刪除", - "common.button.disable": "禁用", "common.button.edit": "编辑", - "common.button.enable": "启用", + "common.button.more": "更多", "common.button.ok": "确定", "common.button.reset": "重置", "common.button.save": "保存", diff --git a/ui/src/i18n/locales/zh/nls.workflow.json b/ui/src/i18n/locales/zh/nls.workflow.json index 78ed5373..831876b2 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.json +++ b/ui/src/i18n/locales/zh/nls.workflow.json @@ -7,14 +7,9 @@ "workflow.action.edit": "编辑工作流", "workflow.action.delete": "删除工作流", "workflow.action.delete.confirm": "确定要删除此工作流吗?", - "workflow.action.discard": "撤销更改", - "workflow.action.discard.confirm": "确定要撤销更改并回退到最近一次发布的版本吗?", - "workflow.action.release": "发布更改", - "workflow.action.release.confirm": "确定要发布更改吗?", - "workflow.action.release.failed.uncompleted": "请先完成流程编排", - "workflow.action.run": "执行", - "workflow.action.run.confirm": "存在未发布的更改,确定要按最近一次发布的版本来执行此工作流吗?", + "workflow.action.enable": "启用", "workflow.action.enable.failed.uncompleted": "请先完成流程编排并发布更改", + "workflow.action.disable": "禁用", "workflow.props.name": "名称", "workflow.props.description": "描述", @@ -28,14 +23,28 @@ "workflow.props.created_at": "创建时间", "workflow.props.updated_at": "更新时间", - "workflow.detail.orchestration.tab": "流程编排", - "workflow.detail.runs.tab": "执行历史", + "workflow.new.title": "新建工作流", + "workflow.new.subtitle": "使用工作流来申请证书、部署上传和发送通知", + "workflow.new.templates.title": "选择工作流模板", + "workflow.new.templates.template.standard.title": "标准模板", + "workflow.new.templates.template.standard.description": "一个包含申请 + 部署 + 通知步骤的标准工作流程。", + "workflow.new.templates.template.blank.title": "空白模板", + "workflow.new.templates.template.blank.description": "从零开始自定义工作流的任务内容。", "workflow.detail.baseinfo.modal.title": "编辑基本信息", "workflow.detail.baseinfo.form.name.label": "名称", "workflow.detail.baseinfo.form.name.placeholder": "请输入工作流名称", "workflow.detail.baseinfo.form.description.label": "描述", "workflow.detail.baseinfo.form.description.placeholder": "请输入工作流描述", + "workflow.detail.orchestration.tab": "流程编排", + "workflow.detail.orchestration.action.discard": "撤销更改", + "workflow.detail.orchestration.action.discard.confirm": "确定要撤销更改并回退到最近一次发布的版本吗?", + "workflow.detail.orchestration.action.release": "发布更改", + "workflow.detail.orchestration.action.release.confirm": "确定要发布更改吗?", + "workflow.detail.orchestration.action.release.failed.uncompleted": "流程编排未完成,请检查是否有节点未设置", + "workflow.detail.orchestration.action.run": "执行", + "workflow.detail.orchestration.action.run.confirm": "此工作流存在未发布的更改,将以最近一次发布的版本为准,确定要继续执行吗?", + "workflow.detail.runs.tab": "执行历史", "workflow.common.certificate.label": "证书", "workflow.node.setting.label": "设置节点", diff --git a/ui/src/pages/workflows/WorkflowDetail.tsx b/ui/src/pages/workflows/WorkflowDetail.tsx index 677ed8cb..d94de3a0 100644 --- a/ui/src/pages/workflows/WorkflowDetail.tsx +++ b/ui/src/pages/workflows/WorkflowDetail.tsx @@ -5,6 +5,7 @@ import { ApartmentOutlined as ApartmentOutlinedIcon, CaretRightOutlined as CaretRightOutlinedIcon, DeleteOutlined as DeleteOutlinedIcon, + DownOutlined as DownOutlinedIcon, EllipsisOutlined as EllipsisOutlinedIcon, HistoryOutlined as HistoryOutlinedIcon, UndoOutlined as UndoOutlinedIcon, @@ -45,8 +46,8 @@ const WorkflowDetail = () => { ); useEffect(() => { // TODO: loading - init(workflowId); - }, [workflowId, init]); + init(workflowId!); + }, [workflowId]); const [tabValue, setTabValue] = useState<"orchestration" | "runs">("orchestration"); @@ -70,10 +71,13 @@ const WorkflowDetail = () => { const [allowDiscard, setAllowDiscard] = useState(false); const [allowRelease, setAllowRelease] = useState(false); + const [allowRun, setAllowRun] = useState(false); useDeepCompareEffect(() => { + const hasReleased = !!workflow.content; const hasChanges = workflow.hasDraft! || !isEqual(workflow.draft, workflow.content); - setAllowDiscard(hasChanges && !workflowRunning); - setAllowRelease(hasChanges && !workflowRunning); + setAllowDiscard(!workflowRunning && hasReleased && hasChanges); + setAllowRelease(!workflowRunning && hasChanges); + setAllowRun(hasReleased); }, [workflow, workflowRunning]); const handleBaseInfoFormFinish = async (values: Pick) => { @@ -86,13 +90,18 @@ const WorkflowDetail = () => { } }; - const handleEnableChange = () => { - if (!workflow.enabled && !isAllNodesValidated(workflow.content!)) { + const handleEnableChange = async () => { + if (!workflow.enabled && (!workflow.content || !isAllNodesValidated(workflow.content))) { messageApi.warning(t("workflow.action.enable.failed.uncompleted")); return; } - switchEnable(); + try { + await switchEnable(); + } catch (err) { + console.error(err); + notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) }); + } }; const handleDeleteClick = () => { @@ -114,18 +123,24 @@ const WorkflowDetail = () => { }; const handleDiscardClick = () => { - alert("TODO"); + modalApi.confirm({ + title: t("workflow.detail.orchestration.action.discard"), + content: t("workflow.detail.orchestration.action.discard.confirm"), + onOk: () => { + alert("TODO"); + }, + }); }; const handleReleaseClick = () => { if (!isAllNodesValidated(workflow.draft!)) { - messageApi.warning(t("workflow.action.release.failed.uncompleted")); + messageApi.warning(t("workflow.detail.orchestration.action.release.failed.uncompleted")); return; } modalApi.confirm({ - title: t("workflow.action.release"), - content: t("workflow.action.release.confirm"), + title: t("workflow.detail.orchestration.action.release"), + content: t("workflow.detail.orchestration.action.release.confirm"), onOk: async () => { try { await save(); @@ -148,8 +163,8 @@ const WorkflowDetail = () => { const { promise, resolve, reject } = Promise.withResolvers(); if (workflow.hasDraft) { modalApi.confirm({ - title: t("workflow.action.run"), - content: t("workflow.action.run.confirm"), + title: t("workflow.detail.orchestration.action.run"), + content: t("workflow.detail.orchestration.action.run.confirm"), onOk: () => resolve(void 0), onCancel: () => reject(), }); @@ -164,7 +179,7 @@ const WorkflowDetail = () => { try { await runWorkflow(workflowId!); - messageApi.warning(t("common.text.operation_succeeded")); + messageApi.success(t("common.text.operation_succeeded")); } catch (err) { if (err instanceof ClientResponseError && err.isAbort) { return; @@ -189,30 +204,33 @@ const WorkflowDetail = () => { style={{ paddingBottom: 0 }} title={workflow.name} extra={[ - - {t("common.button.edit")}} onFinish={handleBaseInfoFormFinish} /> + {t("common.button.edit")}} onFinish={handleBaseInfoFormFinish} />, - + , - , - onClick: () => { - handleDeleteClick(); - }, + , + onClick: () => { + handleDeleteClick(); }, - ], - }} - trigger={["click"]} - > - + , ]} > {workflow.description} @@ -239,13 +257,13 @@ const WorkflowDetail = () => {
- { { key: "discard", disabled: !allowDiscard, - label: t("workflow.action.discard"), + label: t("workflow.detail.orchestration.action.discard"), icon: , onClick: handleDiscardClick, }, diff --git a/ui/src/pages/workflows/WorkflowList.tsx b/ui/src/pages/workflows/WorkflowList.tsx index 8feb4fbb..81ce12ee 100644 --- a/ui/src/pages/workflows/WorkflowList.tsx +++ b/ui/src/pages/workflows/WorkflowList.tsx @@ -245,7 +245,7 @@ const WorkflowList = () => { const handleEnabledChange = async (workflow: WorkflowModel) => { try { - if (!workflow.enabled && !isAllNodesValidated(workflow.content!)) { + if (!workflow.enabled && (!workflow.content || !isAllNodesValidated(workflow.content))) { messageApi.warning(t("workflow.action.enable.failed.uncompleted")); return; } diff --git a/ui/src/pages/workflows/WorkflowNew.tsx b/ui/src/pages/workflows/WorkflowNew.tsx new file mode 100644 index 00000000..8cbe3713 --- /dev/null +++ b/ui/src/pages/workflows/WorkflowNew.tsx @@ -0,0 +1,123 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { PageHeader } from "@ant-design/pro-components"; +import { Card, Col, Row, Spin, Typography, notification } from "antd"; +import { sleep } from "radash"; + +import { type WorkflowModel, initWorkflow } from "@/domain/workflow"; +import { save as saveWorkflow } from "@/repository/workflow"; +import { getErrMsg } from "@/utils/error"; + +const TEMPLATE_KEY_BLANK = "blank" as const; +const TEMPLATE_KEY_STANDARD = "standard" as const; +type TemplateKeys = typeof TEMPLATE_KEY_BLANK | typeof TEMPLATE_KEY_STANDARD; + +const WorkflowNew = () => { + const navigate = useNavigate(); + + const { t } = useTranslation(); + + const [notificationApi, NotificationContextHolder] = notification.useNotification(); + + const templateGridSpans = { + xs: { flex: "100%" }, + md: { flex: "100%" }, + lg: { flex: "50%" }, + xl: { flex: "50%" }, + xxl: { flex: "50%" }, + }; + const [templateSelectKey, setTemplateSelectKey] = useState(); + + const handleTemplateSelect = async (key: TemplateKeys) => { + if (templateSelectKey) return; + + setTemplateSelectKey(key); + + try { + let workflow: WorkflowModel; + + switch (key) { + case TEMPLATE_KEY_BLANK: + workflow = initWorkflow(); + break; + + case TEMPLATE_KEY_STANDARD: + workflow = initWorkflow({ template: "standard" }); + break; + + default: + throw "Invalid args: `key`"; + } + + workflow = await saveWorkflow(workflow); + await sleep(500); + + await navigate(`/workflows/${workflow.id}`, { replace: true }); + } catch (err) { + console.error(err); + notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) }); + + setTemplateSelectKey(undefined); + } + }; + + return ( +
+ {NotificationContextHolder} + + + + {t("workflow.new.subtitle")} + + + +
+
+ +
{t("workflow.new.templates.title")}
+
+ + + + } + hoverable + onClick={() => handleTemplateSelect(TEMPLATE_KEY_STANDARD)} + > +
+ + +
+
+ + + } + hoverable + onClick={() => handleTemplateSelect(TEMPLATE_KEY_BLANK)} + > +
+ + +
+
+ +
+
+
+
+ ); +}; + +export default WorkflowNew; diff --git a/ui/src/stores/workflow/index.ts b/ui/src/stores/workflow/index.ts index 710dd06f..57b22188 100644 --- a/ui/src/stores/workflow/index.ts +++ b/ui/src/stores/workflow/index.ts @@ -1,18 +1,16 @@ import { create } from "zustand"; import { + type WorkflowBranchNode, + type WorkflowModel, + type WorkflowNode, addBranch, addNode, getExecuteMethod, getWorkflowOutputBeforeId, - initWorkflow, removeBranch, removeNode, updateNode, - type WorkflowBranchNode, - type WorkflowModel, - type WorkflowNode, - WorkflowNodeType, } from "@/domain/workflow"; import { get as getWorkflow, save as saveWorkflow } from "@/repository/workflow"; @@ -27,44 +25,29 @@ export type WorkflowState = { getWorkflowOuptutBeforeId: (id: string, type: string) => WorkflowNode[]; switchEnable(): void; save(): void; - init(id?: string): void; + init(id: string): void; setBaseInfo: (name: string, description: string) => void; }; export const useWorkflowStore = create((set, get) => ({ - workflow: { - id: "", - name: "", - type: WorkflowNodeType.Start, - } as WorkflowModel, + workflow: {} as WorkflowModel, initialized: false, - init: async (id?: string) => { - let data = { - id: "", - name: "", - type: "auto", - } as WorkflowModel; - if (!id) { - data = initWorkflow(); - } else { - data = await getWorkflow(id); - } + init: async (id: string) => { + const data = await getWorkflow(id); set({ workflow: data, initialized: true, }); }, + setBaseInfo: async (name: string, description: string) => { const data: Record = { id: (get().workflow.id as string) ?? "", name: name || "", description: description || "", }; - if (!data.id) { - data.draft = get().workflow.draft as WorkflowNode; - } const resp = await saveWorkflow(data); set((state: WorkflowState) => { return { @@ -77,17 +60,18 @@ export const useWorkflowStore = create((set, get) => ({ }; }); }, + switchEnable: async () => { - const root = get().workflow.draft as WorkflowNode; + const root = get().workflow.content as WorkflowNode; const executeMethod = getExecuteMethod(root); const resp = await saveWorkflow({ id: (get().workflow.id as string) ?? "", content: root, enabled: !get().workflow.enabled, - hasDraft: false, type: executeMethod.type, crontab: executeMethod.crontab, }); + set((state: WorkflowState) => { return { workflow: { @@ -95,13 +79,13 @@ export const useWorkflowStore = create((set, get) => ({ id: resp.id, content: resp.content, enabled: resp.enabled, - hasDraft: false, type: resp.type, crontab: resp.crontab, }, }; }); }, + save: async () => { const root = get().workflow.draft as WorkflowNode; const executeMethod = getExecuteMethod(root); @@ -112,6 +96,7 @@ export const useWorkflowStore = create((set, get) => ({ type: executeMethod.type, crontab: executeMethod.crontab, }); + set((state: WorkflowState) => { return { workflow: { @@ -125,6 +110,7 @@ export const useWorkflowStore = create((set, get) => ({ }; }); }, + updateNode: async (node: WorkflowNode | WorkflowBranchNode) => { const newRoot = updateNode(get().workflow.draft as WorkflowNode, node); const resp = await saveWorkflow({ @@ -132,6 +118,7 @@ export const useWorkflowStore = create((set, get) => ({ draft: newRoot, hasDraft: true, }); + set((state: WorkflowState) => { return { workflow: { @@ -143,6 +130,7 @@ export const useWorkflowStore = create((set, get) => ({ }; }); }, + addNode: async (node: WorkflowNode | WorkflowBranchNode, preId: string) => { const newRoot = addNode(get().workflow.draft as WorkflowNode, preId, node); const resp = await saveWorkflow({ @@ -150,6 +138,7 @@ export const useWorkflowStore = create((set, get) => ({ draft: newRoot, hasDraft: true, }); + set((state: WorkflowState) => { return { workflow: { @@ -161,6 +150,7 @@ export const useWorkflowStore = create((set, get) => ({ }; }); }, + addBranch: async (branchId: string) => { const newRoot = addBranch(get().workflow.draft as WorkflowNode, branchId); const resp = await saveWorkflow({ @@ -168,6 +158,7 @@ export const useWorkflowStore = create((set, get) => ({ draft: newRoot, hasDraft: true, }); + set((state: WorkflowState) => { return { workflow: { @@ -179,6 +170,7 @@ export const useWorkflowStore = create((set, get) => ({ }; }); }, + removeBranch: async (branchId: string, index: number) => { const newRoot = removeBranch(get().workflow.draft as WorkflowNode, branchId, index); const resp = await saveWorkflow({ @@ -186,6 +178,7 @@ export const useWorkflowStore = create((set, get) => ({ draft: newRoot, hasDraft: true, }); + set((state: WorkflowState) => { return { workflow: { @@ -197,6 +190,7 @@ export const useWorkflowStore = create((set, get) => ({ }; }); }, + removeNode: async (nodeId: string) => { const newRoot = removeNode(get().workflow.draft as WorkflowNode, nodeId); const resp = await saveWorkflow({ @@ -204,6 +198,7 @@ export const useWorkflowStore = create((set, get) => ({ draft: newRoot, hasDraft: true, }); + set((state: WorkflowState) => { return { workflow: { @@ -215,6 +210,7 @@ export const useWorkflowStore = create((set, get) => ({ }; }); }, + getWorkflowOuptutBeforeId: (id: string, type: string) => { return getWorkflowOutputBeforeId(get().workflow.draft as WorkflowNode, id, type); },