diff --git a/ui/src/components/workflow/Node.tsx b/ui/src/components/workflow/Node.tsx index a8ac3762..bad7bda2 100644 --- a/ui/src/components/workflow/Node.tsx +++ b/ui/src/components/workflow/Node.tsx @@ -44,8 +44,8 @@ const Node = ({ data }: NodeProps) => { return (
- {t(`workflow.node.start.form.executionMethod.options.manual`)}}> - {t(`workflow.node.start.form.executionMethod.options.auto`) + ":"} + {t(`workflow.props.trigger.manual`)}}> + {t(`workflow.props.trigger.auto`) + ":"}
diff --git a/ui/src/components/workflow/PanelBody.tsx b/ui/src/components/workflow/PanelBody.tsx index 848cef2d..2c38e87f 100644 --- a/ui/src/components/workflow/PanelBody.tsx +++ b/ui/src/components/workflow/PanelBody.tsx @@ -1,5 +1,5 @@ import { WorkflowNode, WorkflowNodeType } from "@/domain/workflow"; -import StartForm from "./StartForm"; +import StartNodeForm from "./node/StartNodeForm"; import DeployPanelBody from "./DeployPanelBody"; import ApplyForm from "./ApplyForm"; import NotifyForm from "./NotifyForm"; @@ -11,7 +11,7 @@ const PanelBody = ({ data }: PanelBodyProps) => { const getBody = () => { switch (data.type) { case WorkflowNodeType.Start: - return ; + return ; case WorkflowNodeType.Apply: return ; case WorkflowNodeType.Deploy: diff --git a/ui/src/components/workflow/StartForm.tsx b/ui/src/components/workflow/StartForm.tsx deleted file mode 100644 index d8645660..00000000 --- a/ui/src/components/workflow/StartForm.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Radio } from "antd"; -import { parseExpression } from "cron-parser"; -import { z } from "zod"; - -import { Button } from "../ui/button"; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "../ui/form"; -import { Input } from "../ui/input"; -import { useZustandShallowSelector } from "@/hooks"; -import { useWorkflowStore } from "@/stores/workflow"; -import { WorkflowNode, WorkflowNodeConfig } from "@/domain/workflow"; -import { usePanel } from "./PanelProvider"; -import { RadioChangeEvent } from "antd/lib"; - -const formSchema = z - .object({ - executionMethod: z.string().min(1, "executionMethod is required"), - crontab: z.string(), - }) - .superRefine((data, ctx) => { - if (data.executionMethod != "auto") { - return; - } - try { - parseExpression(data.crontab); - } catch (e) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "crontab is invalid", - path: ["crontab"], - }); - } - }); - -type StartFormProps = { - data: WorkflowNode; -}; - -const i18nPrefix = "workflow.node.start.form"; - -const StartForm = ({ data }: StartFormProps) => { - const { updateNode } = useWorkflowStore(useZustandShallowSelector(["updateNode"])); - const { hidePanel } = usePanel(); - - const { t } = useTranslation(); - - const [method, setMethod] = useState("auto"); - - useEffect(() => { - if (data.config && data.config.executionMethod) { - setMethod(data.config.executionMethod as string); - } else { - setMethod("auto"); - } - }, [data]); - - let config: WorkflowNodeConfig = { - executionMethod: "auto", - crontab: "0 0 * * *", - }; - if (data) config = data.config ?? config; - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - executionMethod: config.executionMethod as string, - crontab: config.crontab as string, - }, - }); - - const onSubmit = async (config: z.infer) => { - updateNode({ ...data, config: { ...config }, validated: true }); - hidePanel(); - }; - - return ( - <> -
- { - e.stopPropagation(); - form.handleSubmit(onSubmit)(e); - }} - className="space-y-8 dark:text-stone-200" - > - ( - - {t(`${i18nPrefix}.executionMethod.label`)} - - { - setMethod(e.target.value); - }} - className="flex space-x-3" - > - {t(`${i18nPrefix}.executionMethod.options.auto`)} - {t(`${i18nPrefix}.executionMethod.options.manual`)} - - - - - - )} - /> - - ( - - )} - /> - -
- -
- - - - ); -}; - -export default StartForm; diff --git a/ui/src/components/workflow/node/StartNodeForm.tsx b/ui/src/components/workflow/node/StartNodeForm.tsx new file mode 100644 index 00000000..1c0e7bb9 --- /dev/null +++ b/ui/src/components/workflow/node/StartNodeForm.tsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useDeepCompareEffect } from "ahooks"; +import { Alert, Button, Form, Input, Radio } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import dayjs from "dayjs"; +import { z } from "zod"; + +import { usePanel } from "../PanelProvider"; +import { useZustandShallowSelector } from "@/hooks"; +import { type WorkflowNode, type WorkflowNodeConfig } from "@/domain/workflow"; +import { useWorkflowStore } from "@/stores/workflow"; +import { validCronExpression, getNextCronExecutions } from "@/utils/cron"; + +export type StartNodeFormProps = { + data: WorkflowNode; +}; + +const initModel = () => { + return { + executionMethod: "auto", + crontab: "0 0 * * *", + } as WorkflowNodeConfig; +}; + +const StartNodeForm = ({ data }: StartNodeFormProps) => { + const { t } = useTranslation(); + + const { updateNode } = useWorkflowStore(useZustandShallowSelector(["updateNode"])); + const { hidePanel } = usePanel(); + + const formSchema = z + .object({ + executionMethod: z.string({ message: t("workflow.nodes.start.form.trigger.placeholder") }).min(1, t("workflow.nodes.start.form.trigger.placeholder")), + crontab: z.string().nullish(), + }) + .superRefine((data, ctx) => { + if (data.executionMethod !== "auto") { + return; + } + + if (!validCronExpression(data.crontab!)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("workflow.nodes.start.form.trigger_cron.errmsg.invalid"), + path: ["crontab"], + }); + } + }); + const formRule = createSchemaFieldRule(formSchema); + const [form] = Form.useForm>(); + const [formPending, setFormPending] = useState(false); + + const [initialValues, setInitialValues] = useState>>((data?.config as Partial>) ?? initModel()); + useDeepCompareEffect(() => { + setInitialValues((data?.config as Partial>) ?? initModel()); + }, [data?.config]); + + const [triggerType, setTriggerType] = useState(data?.config?.executionMethod); + const [triggerCronLastExecutions, setTriggerCronExecutions] = useState([]); + useEffect(() => { + setTriggerType(data?.config?.executionMethod); + setTriggerCronExecutions(getNextCronExecutions(data?.config?.crontab as string, 5)); + }, [data?.config?.executionMethod, data?.config?.crontab]); + + const handleTriggerTypeChange = (value: string) => { + setTriggerType(value); + + if (value === "auto") { + form.setFieldValue("crontab", form.getFieldValue("crontab") || initModel().crontab); + } + }; + + const handleTriggerCronChange = (value: string) => { + setTriggerCronExecutions(getNextCronExecutions(value, 5)); + }; + + const handleFormFinish = async (fields: z.infer) => { + setFormPending(true); + + try { + await updateNode({ ...data, config: { ...fields }, validated: true }); + + hidePanel(); + } finally { + setFormPending(false); + } + }; + + return ( +
+ } + > + handleTriggerTypeChange(e.target.value)}> + {t("workflow.nodes.start.form.trigger.option.auto.label")} + {t("workflow.nodes.start.form.trigger.option.manual.label")} + + + + + + + + + + +
+ ); +}; + +export default StartNodeForm; diff --git a/ui/src/components/workflow/run/WorkflowRunDetailDrawer.tsx b/ui/src/components/workflow/run/WorkflowRunDetailDrawer.tsx index 45ba3661..a9e4af41 100644 --- a/ui/src/components/workflow/run/WorkflowRunDetailDrawer.tsx +++ b/ui/src/components/workflow/run/WorkflowRunDetailDrawer.tsx @@ -2,6 +2,7 @@ import { cloneElement, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useControllableValue } from "ahooks"; import { Alert, Drawer } from "antd"; +import { CircleCheck as CircleCheckIcon, CircleX as CircleXIcon } from "lucide-react"; import Show from "@/components/Show"; import { type WorkflowRunModel } from "@/domain/workflowRun"; @@ -9,6 +10,7 @@ import { type WorkflowRunModel } from "@/domain/workflowRun"; export type WorkflowRunDetailDrawerProps = { data?: WorkflowRunModel; loading?: boolean; + open?: boolean; trigger?: React.ReactElement; onOpenChange?: (open: boolean) => void; }; @@ -43,11 +45,11 @@ const WorkflowRunDetailDrawer = ({ data, loading, trigger, ...props }: WorkflowR setOpen(false)}> - + } /> - - + + } />
diff --git a/ui/src/components/workflow/run/WorkflowRuns.tsx b/ui/src/components/workflow/run/WorkflowRuns.tsx index 0cf76772..09b116da 100644 --- a/ui/src/components/workflow/run/WorkflowRuns.tsx +++ b/ui/src/components/workflow/run/WorkflowRuns.tsx @@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next"; import { useRequest } from "ahooks"; import { Button, Empty, notification, Space, Table, theme, Tooltip, Typography, type TableProps } from "antd"; import { CircleCheck as CircleCheckIcon, CircleX as CircleXIcon, Eye as EyeIcon } from "lucide-react"; -import dayjs from "dayjs"; import { ClientResponseError } from "pocketbase"; import WorkflowRunDetailDrawer from "./WorkflowRunDetailDrawer"; @@ -64,7 +63,7 @@ const WorkflowRuns = ({ className, style }: WorkflowRunsProps) => { key: "startedAt", title: t("workflow_run.props.started_at"), ellipsis: true, - render: (_, record) => { + render: () => { return "TODO"; }, }, @@ -72,7 +71,7 @@ const WorkflowRuns = ({ className, style }: WorkflowRunsProps) => { key: "completedAt", title: t("workflow_run.props.completed_at"), ellipsis: true, - render: (_, record) => { + render: () => { return "TODO"; }, }, diff --git a/ui/src/i18n/locales/en/nls.certificate.json b/ui/src/i18n/locales/en/nls.certificate.json index d1146930..5422f0c2 100644 --- a/ui/src/i18n/locales/en/nls.certificate.json +++ b/ui/src/i18n/locales/en/nls.certificate.json @@ -4,6 +4,7 @@ "certificate.nodata": "No certificates. Please create a workflow to generate certificates! 😀", "certificate.action.view": "View Certificate", + "certificate.action.delete": "Delete Certificate", "certificate.action.download": "Download Certificate", "certificate.props.san": "Name", diff --git a/ui/src/i18n/locales/en/nls.workflow.json b/ui/src/i18n/locales/en/nls.workflow.json index 6f39393b..2fdb4002 100644 --- a/ui/src/i18n/locales/en/nls.workflow.json +++ b/ui/src/i18n/locales/en/nls.workflow.json @@ -21,7 +21,7 @@ "workflow.props.updated_at": "Updated At", "workflow.detail.orchestration.tab": "Orchestration", - "workflow.detail.runs.tab": "Workflow Runs", + "workflow.detail.runs.tab": "History Runs", "workflow.baseinfo.modal.title": "Workflow Base Information", "workflow.baseinfo.form.name.label": "Name", @@ -29,6 +29,18 @@ "workflow.baseinfo.form.description.label": "Description", "workflow.baseinfo.form.description.placeholder": "Please enter description", + "workflow.nodes.start.form.trigger.label": "Trigger", + "workflow.nodes.start.form.trigger.placeholder": "Please select trigger", + "workflow.nodes.start.form.trigger.tooltip": "Auto: Time triggered based on cron expression.
Manual: Manually triggered.", + "workflow.nodes.start.form.trigger.option.auto.label": "Auto", + "workflow.nodes.start.form.trigger.option.manual.label": "Manual", + "workflow.nodes.start.form.trigger_cron.label": "Cron Expression", + "workflow.nodes.start.form.trigger_cron.placeholder": "Please enter cron expression", + "workflow.nodes.start.form.trigger_cron.errmsg.invalid": "Please enter a valid cron expression", + "workflow.nodes.start.form.trigger_cron.tooltip": "Time zone is based on the server.", + "workflow.nodes.start.form.trigger_cron.extra": "Expected execution time for the last 5 times:", + "workflow.nodes.start.form.trigger_cron_alert.content": "Tips: If you have multiple workflows, it is recommended to set them to run at multiple times of the day instead of always running at specific times.

Reference links:
1. Let’s Encrypt rate limits
2. Why should my Let’s Encrypt (ACME) client run at a random time?", + "workflow_run.props.id": "ID", "workflow_run.props.status": "Status", "workflow_run.props.status.succeeded": "Succeeded", @@ -60,13 +72,6 @@ "workflow.node.addBranch.label": "Add Branch", "workflow.node.selectNodeType.label": "Select Node Type", - "workflow.node.start.form.executionMethod.label": "Execution Method", - "workflow.node.start.form.executionMethod.placeholder": "Please select execution method", - "workflow.node.start.form.executionMethod.options.manual": "Manual", - "workflow.node.start.form.executionMethod.options.auto": "Auto", - "workflow.node.start.form.crontab.label": "Crontab", - "workflow.node.start.form.crontab.placeholder": "Please enter crontab", - "workflow.node.notify.form.title.label": "Title", "workflow.node.notify.form.title.placeholder": "Please enter title", "workflow.node.notify.form.content.label": "Content", diff --git a/ui/src/i18n/locales/zh/nls.certificate.json b/ui/src/i18n/locales/zh/nls.certificate.json index 1c4d3d24..0353b38d 100644 --- a/ui/src/i18n/locales/zh/nls.certificate.json +++ b/ui/src/i18n/locales/zh/nls.certificate.json @@ -4,6 +4,7 @@ "certificate.nodata": "暂无证书,新建一个工作流去生成证书吧~ 😀", "certificate.action.view": "查看证书", + "certificate.action.delete": "删除证书", "certificate.action.download": "下载证书", "certificate.props.san": "名称", diff --git a/ui/src/i18n/locales/zh/nls.workflow.json b/ui/src/i18n/locales/zh/nls.workflow.json index c2729c23..0d75ce85 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.json +++ b/ui/src/i18n/locales/zh/nls.workflow.json @@ -29,6 +29,18 @@ "workflow.baseinfo.form.description.label": "描述", "workflow.baseinfo.form.description.placeholder": "请输入工作流描述", + "workflow.nodes.start.form.trigger.label": "触发方式", + "workflow.nodes.start.form.trigger.placeholder": "请选择触发方式", + "workflow.nodes.start.form.trigger.tooltip": "自动触发:基于 Cron 表达式定时触发。
手动触发:手动点击执行触发。", + "workflow.nodes.start.form.trigger.option.auto.label": "自动触发", + "workflow.nodes.start.form.trigger.option.manual.label": "手动触发", + "workflow.nodes.start.form.trigger_cron.label": "Cron 表达式", + "workflow.nodes.start.form.trigger_cron.placeholder": "请输入 Cron 表达式", + "workflow.nodes.start.form.trigger_cron.errmsg.invalid": "请输入正确的 Cron 表达式", + "workflow.nodes.start.form.trigger_cron.tooltip": "时区以服务器设置为准。", + "workflow.nodes.start.form.trigger_cron.extra": "预计最近 5 次执行时间:", + "workflow.nodes.start.form.trigger_cron_alert.content": "小贴士:如果你有多个工作流,建议将它们设置为在一天中的多个时间段运行,而非总是在相同的特定时间。

参考链接:
1. Let’s Encrypt 速率限制
2. 为什么我的 Let’s Encrypt (ACME) 客户端启动时间应当随机?", + "workflow_run.props.id": "ID", "workflow_run.props.status": "状态", "workflow_run.props.status.succeeded": "成功", @@ -60,13 +72,6 @@ "workflow.node.addBranch.label": "添加分支", "workflow.node.selectNodeType.label": "选择节点类型", - "workflow.node.start.form.executionMethod.label": "执行方式", - "workflow.node.start.form.executionMethod.placeholder": "请选择执行方式", - "workflow.node.start.form.executionMethod.options.manual": "手动", - "workflow.node.start.form.executionMethod.options.auto": "自动", - "workflow.node.start.form.crontab.label": "定时表达式", - "workflow.node.start.form.crontab.placeholder": "请输入定时表达式", - "workflow.node.notify.form.title.label": "标题", "workflow.node.notify.form.title.placeholder": "请输入标题", "workflow.node.notify.form.content.label": "内容", diff --git a/ui/src/pages/certificates/CertificateList.tsx b/ui/src/pages/certificates/CertificateList.tsx index b2bb939c..b0517283 100644 --- a/ui/src/pages/certificates/CertificateList.tsx +++ b/ui/src/pages/certificates/CertificateList.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { useRequest } from "ahooks"; import { Button, Divider, Empty, Menu, notification, Radio, Space, Table, theme, Tooltip, Typography, type MenuProps, type TableProps } from "antd"; import { PageHeader } from "@ant-design/pro-components"; -import { Eye as EyeIcon, Filter as FilterIcon } from "lucide-react"; +import { Eye as EyeIcon, Filter as FilterIcon, Trash2 as Trash2Icon } from "lucide-react"; import dayjs from "dayjs"; import { ClientResponseError } from "pocketbase"; @@ -162,6 +162,17 @@ const CertificateList = () => { } /> + + + - } - onFinish={handleBaseInfoFormFinish} - /> + {t("common.button.edit")}} onFinish={handleBaseInfoFormFinish} /> - + + + , + onClick: () => { + handleDeleteClick(); + }, + }, + ], + }} + > +