feat(ui): new workflow routers

This commit is contained in:
Fu Diwei
2025-07-25 19:10:26 +08:00
parent c37cb4afba
commit bc268dfb32
10 changed files with 399 additions and 424 deletions
+12 -15
View File
@@ -21,16 +21,16 @@
"workflow.action.enable.button": "Activate",
"workflow.action.enable.errmsg.uncompleted": "Please complete the orchestration and publish the changes first",
"workflow.action.disable.button": "Deactivate",
"workflow.action.release.button": "Release",
"workflow.action.release.modal.title": "Release changes",
"workflow.action.release.modal.content": "Are you sure to release your changes?",
"workflow.action.release.errmsg.uncompleted": "Please complete the orchestration first",
"workflow.action.discard.button": "Discard",
"workflow.action.discard.modal.title": "Discard changes",
"workflow.action.discard.modal.content": "Are you sure to discard your changes?",
"workflow.action.publish.button": "Publish",
"workflow.action.publish.modal.title": "Publish changes",
"workflow.action.publish.modal.content": "Are you sure to publish your changes?",
"workflow.action.publish.errmsg.uncompleted": "Please complete the orchestration first",
"workflow.action.rollback.button": "Rollback",
"workflow.action.rollback.modal.title": "Rollback changes",
"workflow.action.rollback.modal.content": "Are you sure to rollback your changes to the last published version?",
"workflow.action.run.button": "Run",
"workflow.action.run.modal.title": "Run workflow",
"workflow.action.run.modal.content": "You have unreleased changes. Do you really want to run this workflow based on the latest released version?",
"workflow.action.run.modal.content": "You have unpublished changes. Do you really want to run this workflow based on the last published version?",
"workflow.action.run.prompt": "Running... Please check the history later",
"workflow.props.name": "Name",
@@ -55,18 +55,15 @@
"workflow.new.templates.template.certtest.description": "A monitoring operating procedure that includes monitoring, and notification steps.",
"workflow.new.templates.template.empty.title": "Empty template",
"workflow.new.templates.template.empty.description": "Customize all the contents of the workflow from the beginning.",
"workflow.new.modal.title": "Create workflow",
"workflow.new.modal.form.name.label": "Name",
"workflow.new.modal.form.name.placeholder": "Please enter workflow name",
"workflow.new.modal.form.description.label": "Description (Optional)",
"workflow.new.modal.form.description.placeholder": "Please enter workflow description",
"workflow.new.templates.default_name": "Untitled workflow",
"workflow.new.templates.default_description": "Created at {{date}}",
"workflow.detail.baseinfo.modal.title": "Workflow base information",
"workflow.detail.baseinfo.form.name.label": "Name",
"workflow.detail.baseinfo.form.name.placeholder": "Please enter workflow name",
"workflow.detail.baseinfo.form.description.label": "Description (Optional)",
"workflow.detail.baseinfo.form.description.placeholder": "Please enter workflow description",
"workflow.detail.orchestration.tab": "Orchestration",
"workflow.detail.orchestration.draft.alert": "The orchestration is not released yet.",
"workflow.detail.design.tab": "Orchestration",
"workflow.detail.design.draft.alert": "The orchestration is not released yet.",
"workflow.detail.runs.tab": "History runs"
}
+11 -14
View File
@@ -21,13 +21,13 @@
"workflow.action.enable.button": "启用",
"workflow.action.enable.errmsg.uncompleted": "请先完成流程编排并发布更改",
"workflow.action.disable.button": "停用",
"workflow.action.release.button": "发布更改",
"workflow.action.release.modal.title": "发布更改",
"workflow.action.release.modal.content": "确定要发布更改吗?",
"workflow.action.release.errmsg.uncompleted": "流程编排未完成,请检查是否有节点未配置",
"workflow.action.discard.button": "撤销更改",
"workflow.action.discard.modal.title": "撤销更改",
"workflow.action.discard.modal.content": "确定要撤销更改并回退到最近一次发布的版本吗?",
"workflow.action.publish.button": "发布更改",
"workflow.action.publish.modal.title": "发布更改",
"workflow.action.publish.modal.content": "确定要发布更改吗?",
"workflow.action.publish.errmsg.uncompleted": "流程编排未完成,请检查是否有节点未配置",
"workflow.action.rollback.button": "回退更改",
"workflow.action.rollback.modal.title": "回退更改",
"workflow.action.rollback.modal.content": "确定要回退到最近一次发布的版本吗?",
"workflow.action.run.button": "执行",
"workflow.action.run.modal.title": "执行工作流",
"workflow.action.run.modal.content": "你有尚未发布的更改。确定要以最近一次发布的版本继续执行吗?",
@@ -55,18 +55,15 @@
"workflow.new.templates.template.certtest.description": "一个包含证书监控 + 消息通知步骤的工作流程。",
"workflow.new.templates.template.empty.title": "空白模板",
"workflow.new.templates.template.empty.description": "从零开始自定义工作流的任务内容。",
"workflow.new.modal.title": "新建工作流",
"workflow.new.modal.form.name.label": "名称",
"workflow.new.modal.form.name.placeholder": "请输入工作流名称",
"workflow.new.modal.form.description.label": "描述(可选)",
"workflow.new.modal.form.description.placeholder": "请输入工作流描述",
"workflow.new.templates.default_name": "未命名工作流",
"workflow.new.templates.default_description": "于 {{date}} 创建",
"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.draft.alert": "当前编排有未发布的更改。",
"workflow.detail.design.tab": "流程编排",
"workflow.detail.design.draft.alert": "当前编排有未发布的更改。",
"workflow.detail.runs.tab": "执行历史"
}
+11 -12
View File
@@ -16,17 +16,22 @@ const Settings = () => {
["ssl-provider", "settings.sslprovider.tab", <IconPlugConnected size="1em" />],
["persistence", "settings.persistence.tab", <IconDatabaseCog size="1em" />],
] satisfies [string, string, React.ReactElement][];
const [menuKey, setMenuValue] = useState("account");
const [menuKey, setMenuValue] = useState(menus[0][0]);
useEffect(() => {
const path = location.pathname.split("/")[2];
if (!path) {
const subpath = location.pathname.split("/")[2];
if (!subpath) {
navigate("/settings/account");
return;
}
setMenuValue(path);
setMenuValue(subpath);
}, [location.pathname]);
const handleMenuClick = ({ key }: { key: string }) => {
setMenuValue(key);
navigate(`/settings/${key}`);
};
return (
<div className="px-6 py-4">
<div className="mx-auto max-w-320">
@@ -46,10 +51,7 @@ const Settings = () => {
</span>
),
}))}
onClick={({ key }) => {
setMenuValue(key);
navigate(`/settings/${key}`);
}}
onClick={handleMenuClick}
/>
</div>
@@ -68,10 +70,7 @@ const Settings = () => {
</span>
),
}))}
onClick={({ key }) => {
setMenuValue(key);
navigate(`/settings/${key}`);
}}
onClick={handleMenuClick}
/>
</div>
+47 -210
View File
@@ -1,25 +1,21 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { IconArrowBackUp, IconChevronDown, IconDots, IconHistory, IconPlayerPlay, IconRobot, IconTrash } from "@tabler/icons-react";
import { Alert, App, Button, Card, Dropdown, Flex, Form, Input, Segmented, Space } from "antd";
import { Outlet, useLocation, useNavigate, useParams } from "react-router-dom";
import { IconChevronDown, IconHistory, IconRobot, IconTrash } from "@tabler/icons-react";
import { App, Button, Dropdown, Flex, Form, Input, Segmented, Skeleton } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { isEqual } from "radash";
import { z } from "zod";
import { startRun as startWorkflowRun } from "@/api/workflows";
import ModalForm from "@/components/ModalForm";
import Show from "@/components/Show";
import WorkflowElementsContainer from "@/components/workflow/WorkflowElementsContainer";
import WorkflowRuns from "@/components/workflow/WorkflowRuns";
import { isAllNodesValidated } from "@/domain/workflow";
import { WORKFLOW_RUN_STATUSES } from "@/domain/workflowRun";
import { useAntdForm, useZustandShallowSelector } from "@/hooks";
import { remove as removeWorkflow, subscribe as subscribeWorkflow, unsubscribe as unsubscribeWorkflow } from "@/repository/workflow";
import { remove as removeWorkflow } from "@/repository/workflow";
import { useWorkflowStore } from "@/stores/workflow";
import { getErrMsg } from "@/utils/error";
const WorkflowDetail = () => {
const location = useLocation();
const navigate = useNavigate();
const { t } = useTranslation();
@@ -27,9 +23,7 @@ const WorkflowDetail = () => {
const { message, modal, notification } = App.useApp();
const { id: workflowId } = useParams();
const { workflow, initialized, ...workflowState } = useWorkflowStore(
useZustandShallowSelector(["workflow", "initialized", "init", "destroy", "setEnabled", "release", "discard"])
);
const { workflow, initialized, ...workflowState } = useWorkflowStore(useZustandShallowSelector(["workflow", "initialized", "init", "destroy", "setEnabled"]));
useEffect(() => {
workflowState.init(workflowId!);
@@ -38,43 +32,27 @@ const WorkflowDetail = () => {
};
}, [workflowId]);
const [tabValue, setTabValue] = useState<"orchestration" | "runs">("orchestration");
const [isPendingOrRunning, setIsPendingOrRunning] = useState(false);
const lastRunStatus = useMemo(() => workflow.lastRunStatus, [workflow]);
const [allowDiscard, setAllowDiscard] = useState(false);
const [allowRelease, setAllowRelease] = useState(false);
const [allowRun, setAllowRun] = useState(false);
const tabs = [
["design", "workflow.detail.design.tab", <IconRobot size="1em" />],
["runs", "workflow.detail.runs.tab", <IconHistory size="1em" />],
] satisfies [string, string, React.ReactElement][];
const [tabValue, setTabValue] = useState(tabs[0][0]);
useEffect(() => {
setIsPendingOrRunning(lastRunStatus == WORKFLOW_RUN_STATUSES.PENDING || lastRunStatus == WORKFLOW_RUN_STATUSES.RUNNING);
}, [lastRunStatus]);
useEffect(() => {
if (!!workflowId && isPendingOrRunning) {
subscribeWorkflow(workflowId, (cb) => {
if (cb.record.lastRunStatus !== WORKFLOW_RUN_STATUSES.PENDING && cb.record.lastRunStatus !== WORKFLOW_RUN_STATUSES.RUNNING) {
setIsPendingOrRunning(false);
unsubscribeWorkflow(workflowId);
}
});
return () => {
unsubscribeWorkflow(workflowId);
};
const subpath = location.pathname.split("/")[3];
if (!tabs.map((tab) => tab[0]).includes(subpath)) {
navigate(`/workflows/${workflowId}/${tabs[0][0]}`, { replace: true });
return;
}
}, [workflowId, isPendingOrRunning]);
useEffect(() => {
const hasReleased = !!workflow.content;
const hasChanges = workflow.hasDraft! || !isEqual(workflow.draft, workflow.content);
setAllowDiscard(!isPendingOrRunning && hasReleased && hasChanges);
setAllowRelease(!isPendingOrRunning && hasChanges);
setAllowRun(hasReleased);
}, [workflow.content, workflow.draft, workflow.hasDraft, isPendingOrRunning]);
setTabValue(subpath);
}, [location.pathname, workflowId]);
const handleEnableChange = async () => {
const handleTabChange = (value: string) => {
setTabValue(value);
navigate(`/workflows/${workflowId}/${value}`);
};
const handleEnableClick = async () => {
if (!workflow.enabled && (!workflow.content || !isAllNodesValidated(workflow.content))) {
message.warning(t("workflow.action.enable.errmsg.uncompleted"));
return;
@@ -113,85 +91,6 @@ const WorkflowDetail = () => {
});
};
const handleDiscardClick = () => {
modal.confirm({
title: t("workflow.action.discard.modal.title"),
content: t("workflow.action.discard.modal.content"),
onOk: async () => {
try {
await workflowState.discard();
message.success(t("common.text.operation_succeeded"));
} catch (err) {
console.error(err);
notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
}
},
});
};
const handleReleaseClick = () => {
if (!isAllNodesValidated(workflow.draft!)) {
message.warning(t("workflow.action.release.errmsg.uncompleted"));
return;
}
modal.confirm({
title: t("workflow.action.release.modal.title"),
content: t("workflow.action.release.modal.content"),
onOk: async () => {
try {
await workflowState.release();
message.success(t("common.text.operation_succeeded"));
} catch (err) {
console.error(err);
notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
}
},
});
};
const handleRunClick = () => {
const { promise, resolve, reject } = Promise.withResolvers();
if (workflow.hasDraft) {
modal.confirm({
title: t("workflow.action.run.modal.title"),
content: t("workflow.action.run.modal.content"),
onOk: () => resolve(void 0),
onCancel: () => reject(),
});
} else {
resolve(void 0);
}
promise.then(async () => {
let unsubscribeFn: Awaited<ReturnType<typeof subscribeWorkflow>> | undefined = undefined;
try {
setIsPendingOrRunning(true);
// subscribe before running workflow
unsubscribeFn = await subscribeWorkflow(workflowId!, (e) => {
if (e.record.lastRunStatus !== WORKFLOW_RUN_STATUSES.PENDING && e.record.lastRunStatus !== WORKFLOW_RUN_STATUSES.RUNNING) {
setIsPendingOrRunning(false);
unsubscribeFn?.();
}
});
await startWorkflowRun(workflowId!);
message.info(t("workflow.action.run.prompt"));
} catch (err) {
setIsPendingOrRunning(false);
unsubscribeFn?.();
console.error(err);
message.warning(t("common.text.operation_failed"));
}
});
};
return (
<div className="flex size-full flex-col">
<div className="px-6 py-4">
@@ -205,7 +104,7 @@ const WorkflowDetail = () => {
{initialized
? [
<WorkflowBaseInfoModal key="edit" trigger={<Button>{t("common.button.edit")}</Button>} />,
<Button key="enable" onClick={handleEnableChange}>
<Button key="enable" onClick={handleEnableClick}>
{workflow.enabled ? t("workflow.action.disable.button") : t("workflow.action.enable.button")}
</Button>,
<Dropdown
@@ -214,7 +113,7 @@ const WorkflowDetail = () => {
items: [
{
key: "delete",
label: t("common.button.delete"),
label: t("workflow.action.delete.button"),
danger: true,
icon: <IconTrash size="1.25em" />,
onClick: () => {
@@ -237,98 +136,36 @@ const WorkflowDetail = () => {
<div className="absolute -bottom-12 left-1/2 z-1 -translate-x-1/2">
<Segmented
className="shadow"
options={[
{
value: "orchestration",
label: <span className="px-2 text-sm">{t("workflow.detail.orchestration.tab")}</span>,
icon: (
<span className="anticon scale-125" role="img">
<IconRobot size="1em" />
</span>
),
},
{
value: "runs",
label: <span className="px-2 text-sm">{t("workflow.detail.runs.tab")}</span>,
icon: (
<span className="anticon scale-125" role="img">
<IconHistory size="1em" />
</span>
),
},
]}
options={tabs.map(([key, label, icon]) => ({
value: key,
label: <span className="px-2 text-sm">{t(label)}</span>,
icon: (
<span className="anticon scale-125" role="img">
{icon}
</span>
),
}))}
size="large"
value={tabValue}
defaultValue="orchestration"
onChange={(value) => {
setTabValue(value as typeof tabValue);
}}
onChange={handleTabChange}
/>
</div>
</div>
</div>
<Show when={tabValue === "orchestration"}>
<div className="min-h-[360px] flex-1 overflow-hidden p-4">
<Card
className="size-full overflow-hidden"
styles={{
body: {
position: "relative",
height: "100%",
padding: initialized ? 0 : undefined,
},
}}
loading={!initialized}
>
<div className="absolute inset-x-6 top-4 z-2 mx-auto flex max-w-320 items-center justify-between gap-4 pt-6">
<div className="flex-1 overflow-hidden">
<Show when={workflow.hasDraft!}>
<Alert message={<div className="truncate">{t("workflow.detail.orchestration.draft.alert")}</div>} showIcon type="warning" />
</Show>
</div>
<div className="flex justify-end">
<Space>
<Button disabled={!allowRun} icon={<IconPlayerPlay size="1.25em" />} loading={isPendingOrRunning} type="primary" onClick={handleRunClick}>
{t("workflow.action.run.button")}
</Button>
<Space.Compact>
<Button color="primary" disabled={!allowRelease} variant="outlined" onClick={handleReleaseClick}>
{t("workflow.action.release.button")}
</Button>
<Dropdown
menu={{
items: [
{
key: "discard",
disabled: !allowDiscard,
label: t("workflow.action.discard.button"),
icon: <IconArrowBackUp size="1.25em" />,
onClick: handleDiscardClick,
},
],
}}
trigger={["click"]}
>
<Button color="primary" disabled={!allowDiscard} icon={<IconDots size="1.25em" />} variant="outlined" />
</Dropdown>
</Space.Compact>
</Space>
</div>
<div className="p-4">
<Show
when={initialized}
fallback={
<div className="mx-auto max-w-320 pt-12">
<Skeleton active />
</div>
<WorkflowElementsContainer className="pt-24" />
</Card>
</div>
</Show>
<Show when={tabValue === "runs"}>
<div className="p-4">
<div className="mx-auto max-w-320 pt-6">
<WorkflowRuns workflowId={workflowId!} />
</div>
</div>
</Show>
}
>
<Outlet />
</Show>
</div>
</div>
);
};
@@ -0,0 +1,193 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconArrowBackUp, IconDots, IconPlayerPlay } from "@tabler/icons-react";
import { Alert, App, Button, Card, Dropdown, Space } from "antd";
import { isEqual } from "radash";
import { startRun as startWorkflowRun } from "@/api/workflows";
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 { subscribe as subscribeWorkflow, unsubscribe as unsubscribeWorkflow } from "@/repository/workflow";
import { useWorkflowStore } from "@/stores/workflow";
import { getErrMsg } from "@/utils/error";
const WorkflowDetailDesign = () => {
const { t } = useTranslation();
const { message, modal, notification } = App.useApp();
const { workflow, ...workflowState } = useWorkflowStore(useZustandShallowSelector(["workflow", "publish", "rollback"]));
const [isPendingOrRunning, setIsPendingOrRunning] = useState(false);
const lastRunStatus = useMemo(() => workflow.lastRunStatus, [workflow]);
const [allowRollback, setAllowRollback] = useState(false);
const [allowPublish, setAllowPublish] = useState(false);
const [allowRun, setAllowRun] = useState(false);
useEffect(() => {
setIsPendingOrRunning(lastRunStatus == WORKFLOW_RUN_STATUSES.PENDING || lastRunStatus == WORKFLOW_RUN_STATUSES.RUNNING);
}, [lastRunStatus]);
useEffect(() => {
if (isPendingOrRunning) {
subscribeWorkflow(workflow.id, (cb) => {
if (cb.record.lastRunStatus !== WORKFLOW_RUN_STATUSES.PENDING && cb.record.lastRunStatus !== WORKFLOW_RUN_STATUSES.RUNNING) {
setIsPendingOrRunning(false);
unsubscribeWorkflow(workflow.id);
}
});
return () => {
unsubscribeWorkflow(workflow.id);
};
}
}, [workflow.id, isPendingOrRunning]);
useEffect(() => {
const hasContent = !!workflow.content;
const hasChanges = workflow.hasDraft! || !isEqual(workflow.draft, workflow.content);
setAllowRollback(!isPendingOrRunning && hasContent && hasChanges);
setAllowPublish(!isPendingOrRunning && hasChanges);
setAllowRun(hasContent);
}, [workflow.content, workflow.draft, workflow.hasDraft, isPendingOrRunning]);
const handleRollbackClick = () => {
modal.confirm({
title: t("workflow.action.rollback.modal.title"),
content: t("workflow.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.action.publish.errmsg.uncompleted"));
return;
}
modal.confirm({
title: t("workflow.action.publish.modal.title"),
content: t("workflow.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) });
}
},
});
};
const handleRunClick = () => {
const { promise, resolve, reject } = Promise.withResolvers();
if (workflow.hasDraft) {
modal.confirm({
title: t("workflow.action.run.modal.title"),
content: t("workflow.action.run.modal.content"),
onOk: () => resolve(void 0),
onCancel: () => reject(),
});
} else {
resolve(void 0);
}
promise.then(async () => {
let unsubscribeFn: Awaited<ReturnType<typeof subscribeWorkflow>> | undefined = undefined;
try {
setIsPendingOrRunning(true);
// subscribe before running workflow
unsubscribeFn = await subscribeWorkflow(workflow.id, (e) => {
if (e.record.lastRunStatus !== WORKFLOW_RUN_STATUSES.PENDING && e.record.lastRunStatus !== WORKFLOW_RUN_STATUSES.RUNNING) {
setIsPendingOrRunning(false);
unsubscribeFn?.();
}
});
await startWorkflowRun(workflow.id);
message.info(t("workflow.action.run.prompt"));
} catch (err) {
setIsPendingOrRunning(false);
unsubscribeFn?.();
console.error(err);
message.warning(t("common.text.operation_failed"));
}
});
};
return (
<div className="min-h-[360px] flex-1 overflow-hidden">
<Card
className="size-full overflow-hidden"
styles={{
body: {
position: "relative",
height: "100%",
padding: 0,
},
}}
>
<div className="pt-9">
<div className="absolute inset-x-6 z-2 mx-auto flex max-w-320 items-center justify-between gap-4">
<div className="flex-1 overflow-hidden">
<Show when={workflow.hasDraft!}>
<Alert message={<div className="truncate">{t("workflow.detail.design.draft.alert")}</div>} showIcon type="warning" />
</Show>
</div>
<div className="flex justify-end">
<Space>
<Button disabled={!allowRun} icon={<IconPlayerPlay size="1.25em" />} loading={isPendingOrRunning} type="primary" onClick={handleRunClick}>
{t("workflow.action.run.button")}
</Button>
<Space.Compact>
<Button color="primary" disabled={!allowPublish} variant="outlined" onClick={handlePublishClick}>
{t("workflow.action.publish.button")}
</Button>
<Dropdown
menu={{
items: [
{
key: "rollback",
disabled: !allowRollback,
label: t("workflow.action.rollback.button"),
icon: <IconArrowBackUp size="1.25em" />,
onClick: handleRollbackClick,
},
],
}}
trigger={["click"]}
>
<Button color="primary" icon={<IconDots size="1.25em" />} variant="outlined" />
</Dropdown>
</Space.Compact>
</Space>
</div>
</div>
<WorkflowElementsContainer className="pt-12" />
</div>
</Card>
</div>
);
};
export default WorkflowDetailDesign;
@@ -8,32 +8,29 @@ import { ClientResponseError } from "pocketbase";
import { cancelRun as cancelWorkflowRun } from "@/api/workflows";
import Empty from "@/components/Empty";
import WorkflowRunDetailDrawer from "@/components/workflow/WorkflowRunDetailDrawer";
import WorkflowStatusTag from "@/components/workflow/WorkflowStatusTag";
import { WORKFLOW_TRIGGERS } from "@/domain/workflow";
import { WORKFLOW_RUN_STATUSES, type WorkflowRunModel } from "@/domain/workflowRun";
import { useAppSettings } from "@/hooks";
import { useAppSettings, useZustandShallowSelector } from "@/hooks";
import {
list as listWorkflowRuns,
remove as removeWorkflowRun,
subscribe as subscribeWorkflowRun,
unsubscribe as unsubscribeWorkflowRun,
} from "@/repository/workflowRun";
import { useWorkflowStore } from "@/stores/workflow";
import { getErrMsg } from "@/utils/error";
import WorkflowRunDetailDrawer from "./WorkflowRunDetailDrawer";
export interface WorkflowRunsProps {
className?: string;
style?: React.CSSProperties;
workflowId: string;
}
const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
const WorkflowDetailRuns = () => {
const { t } = useTranslation();
const { modal, notification } = App.useApp();
const { appSettings: globalAppSettings } = useAppSettings();
const { workflow } = useWorkflowStore(useZustandShallowSelector(["workflow"]));
const [page, setPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(globalAppSettings.defaultPerPage!);
@@ -167,13 +164,13 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
} = useRequest(
() => {
return listWorkflowRuns({
workflowId: workflowId,
workflowId: workflow.id,
page: page,
perPage: pageSize,
});
},
{
refreshDeps: [workflowId, page, pageSize],
refreshDeps: [workflow.id, page, pageSize],
onSuccess: (res) => {
setTableData(res.items);
setTableTotal(res.totalItems);
@@ -238,7 +235,7 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
content: t("workflow_run.action.cancel.modal.content"),
onOk: async () => {
try {
const resp = await cancelWorkflowRun(workflowId, workflowRun.id);
const resp = await cancelWorkflowRun(workflow.id, workflowRun.id);
if (resp) {
refreshData();
}
@@ -277,46 +274,48 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
};
return (
<div className={className} style={style}>
<Alert className="mb-4" message={<span dangerouslySetInnerHTML={{ __html: t("workflow_run.deletion.alert") }}></span>} showIcon type="info" />
<Alert className="mb-4" message={<span dangerouslySetInnerHTML={{ __html: t("workflow_run.cancellation.alert") }}></span>} showIcon type="info" />
<div className="mx-auto max-w-320">
<div className="pt-9">
<Alert className="mb-4" message={<span dangerouslySetInnerHTML={{ __html: t("workflow_run.deletion.alert") }}></span>} showIcon type="info" />
<Alert className="mb-4" message={<span dangerouslySetInnerHTML={{ __html: t("workflow_run.cancellation.alert") }}></span>} showIcon type="info" />
<Table<WorkflowRunModel>
columns={tableColumns}
dataSource={tableData}
loading={loading}
locale={{
emptyText: loading ? (
<Skeleton />
) : (
<Empty
title={t("common.text.nodata")}
description={loadedError ? getErrMsg(loadedError) : t("workflow_run.nodata.description")}
icon={<IconHistory size={24} />}
/>
),
}}
pagination={{
current: page,
pageSize: pageSize,
total: tableTotal,
showSizeChanger: true,
onChange: handlePaginationChange,
onShowSizeChange: handlePaginationChange,
}}
rowClassName="cursor-pointer"
rowKey={(record) => record.id}
scroll={{ x: "max(100%, 960px)" }}
onRow={(record) => ({
onClick: () => {
handleRecordDetailClick(record);
},
})}
/>
<Table<WorkflowRunModel>
columns={tableColumns}
dataSource={tableData}
loading={loading}
locale={{
emptyText: loading ? (
<Skeleton />
) : (
<Empty
title={t("common.text.nodata")}
description={loadedError ? getErrMsg(loadedError) : t("workflow_run.nodata.description")}
icon={<IconHistory size={24} />}
/>
),
}}
pagination={{
current: page,
pageSize: pageSize,
total: tableTotal,
showSizeChanger: true,
onChange: handlePaginationChange,
onShowSizeChange: handlePaginationChange,
}}
rowClassName="cursor-pointer"
rowKey={(record) => record.id}
scroll={{ x: "max(100%, 960px)" }}
onRow={(record) => ({
onClick: () => {
handleRecordDetailClick(record);
},
})}
/>
<WorkflowRunDetailDrawer {...detailDrawerProps} />
<WorkflowRunDetailDrawer {...detailDrawerProps} />
</div>
</div>
);
};
export default WorkflowRuns;
export default WorkflowDetailRuns;
+23 -18
View File
@@ -79,22 +79,22 @@ const WorkflowList = () => {
title: t("workflow.props.state"),
defaultFilteredValue: searchParams.has("state") ? [searchParams.get("state") as string] : undefined,
render: (_, record) => {
const enabled = record.enabled;
return (
<div
onClick={(e) => {
e.stopPropagation();
<Switch
checked={record.enabled}
onChange={() => {
handleRecordActiveChange(record);
}}
>
<Switch
checked={enabled}
onChange={() => {
handleRecordActiveChange(record);
}}
/>
</div>
/>
);
},
onCell: () => {
return {
onClick: (e) => {
e.stopPropagation();
},
};
},
},
{
key: "lastRun",
@@ -102,12 +102,17 @@ const WorkflowList = () => {
sorter: true,
sortOrder: sorter.columnKey === "lastRun" ? sorter.order : undefined,
render: (_, record) => {
return (
<Flex gap="small">
<WorkflowStatusIcon color={true} size="1.25em" status={record.lastRunStatus!} />
<Typography.Text>{record.lastRunTime ? dayjs(record.lastRunTime!).format("YYYY-MM-DD HH:mm:ss") : ""}</Typography.Text>
</Flex>
);
const { lastRunStatus, lastRunTime } = record;
if (!lastRunStatus) {
return <></>;
} else {
return (
<Flex gap="small">
<WorkflowStatusIcon color={true} size="1.25em" status={lastRunStatus!} />
<Typography.Text>{lastRunTime ? dayjs(record.lastRunTime!).format("YYYY-MM-DD HH:mm:ss") : ""}</Typography.Text>
</Flex>
);
}
},
},
{
+38 -102
View File
@@ -1,18 +1,15 @@
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { App, Card, Col, Form, Input, type InputRef, Row, Spin, Typography } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
import { App, Card, Col, Row, Spin, Typography } from "antd";
import dayjs from "dayjs";
import ModalForm from "@/components/ModalForm";
import { type WorkflowModel, initWorkflow } from "@/domain/workflow";
import { useAntdForm } from "@/hooks";
import { save as saveWorkflow } from "@/repository/workflow";
import { getErrMsg } from "@/utils/error";
const TEMPLATE_KEY_STANDARD = "standard" as const;
const TEMPLATE_KEY_CERTTEST = "monitor" as const;
const TEMPLATE_KEY_CERTTEST = "certtest" as const;
const TEMPLATE_KEY_EMPTY = "empty" as const;
type TemplateKeys = typeof TEMPLATE_KEY_EMPTY | typeof TEMPLATE_KEY_CERTTEST | typeof TEMPLATE_KEY_STANDARD;
@@ -32,83 +29,45 @@ const WorkflowNew = () => {
};
const [templateSelectKey, setTemplateSelectKey] = useState<TemplateKeys>();
const formSchema = z.object({
name: z
.string()
.min(1, t("workflow.new.modal.form.name.placeholder"))
.max(64, t("common.errmsg.string_max", { max: 64 })),
description: z
.string()
.max(256, t("common.errmsg.string_max", { max: 256 }))
.nullish(),
});
const formRule = createSchemaFieldRule(formSchema);
const {
form: formInst,
formPending,
formProps,
submit: submitForm,
} = useAntdForm<z.infer<typeof formSchema>>({
initialValues: {
name: "",
description: "",
},
onSubmit: async (values) => {
try {
let workflow: WorkflowModel;
const [pending, setPending] = useState(false);
switch (templateSelectKey) {
case TEMPLATE_KEY_EMPTY:
workflow = initWorkflow();
break;
const handleTemplateClick = async (key: TemplateKeys) => {
if (pending) return;
case TEMPLATE_KEY_STANDARD:
workflow = initWorkflow({ template: "standard" });
break;
case TEMPLATE_KEY_CERTTEST:
workflow = initWorkflow({ template: "certtest" });
break;
default:
throw "Invalid state: `templateSelectKey`";
}
workflow.name = values.name?.trim() ?? workflow.name;
workflow.description = values.description?.trim() ?? workflow.description;
workflow = await saveWorkflow(workflow);
navigate(`/workflows/${workflow.id}`, { replace: true });
} catch (err) {
notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
throw err;
}
},
});
const [formModalOpen, setFormModalOpen] = useState(false);
useEffect(() => {
if (formModalOpen) {
setTimeout(() => inputRef.current?.focus({ cursor: "end" }), 1);
} else {
setTemplateSelectKey(undefined);
formInst.resetFields();
}
}, [formModalOpen]);
const inputRef = useRef<InputRef>(null);
const handleTemplateClick = (key: TemplateKeys) => {
setTemplateSelectKey(key);
setFormModalOpen(true);
};
const handleModalOpenChange = (open: boolean) => {
setFormModalOpen(open);
};
try {
let workflow: WorkflowModel;
const handleModalFormFinish = () => {
return submitForm();
switch (key) {
case TEMPLATE_KEY_EMPTY:
workflow = initWorkflow();
break;
case TEMPLATE_KEY_STANDARD:
workflow = initWorkflow({ template: "standard" });
break;
case TEMPLATE_KEY_CERTTEST:
workflow = initWorkflow({ template: "certtest" });
break;
default:
throw "Invalid state: `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) {
notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
throw err;
} finally {
setPending(false);
setTemplateSelectKey(undefined);
}
};
return (
@@ -179,29 +138,6 @@ const WorkflowNew = () => {
</Col>
</Row>
</div>
<ModalForm
{...formProps}
autoFocus
disabled={formPending}
layout="vertical"
form={formInst}
modalProps={{ destroyOnHidden: true }}
okText={t("common.button.submit")}
open={formModalOpen}
title={t(`workflow.new.modal.title`)}
width={480}
onFinish={handleModalFormFinish}
onOpenChange={handleModalOpenChange}
>
<Form.Item name="name" label={t("workflow.new.modal.form.name.label")} rules={[formRule]}>
<Input ref={inputRef} autoFocus placeholder={t("workflow.new.modal.form.name.placeholder")} />
</Form.Item>
<Form.Item name="description" label={t("workflow.new.modal.form.description.label")} rules={[formRule]}>
<Input placeholder={t("workflow.new.modal.form.description.placeholder")} />
</Form.Item>
</ModalForm>
</div>
);
};
+12
View File
@@ -14,6 +14,8 @@ import SettingsAppearance from "@/pages/settings/SettingsAppearance";
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 WorkflowDetailRuns from "@/pages/workflows/WorkflowDetailRuns";
import WorkflowList from "@/pages/workflows/WorkflowList";
import WorkflowNew from "@/pages/workflows/WorkflowNew";
@@ -49,6 +51,16 @@ export const router = createHashRouter([
{
path: "/workflows/:id",
element: <WorkflowDetail />,
children: [
{
path: "/workflows/:id/design",
element: <WorkflowDetailDesign />,
},
{
path: "/workflows/:id/runs",
element: <WorkflowDetailRuns />,
},
],
},
{
path: "/settings",
+4 -4
View File
@@ -23,8 +23,8 @@ export type WorkflowState = {
init(id: string): void;
setBaseInfo: (name: string, description: string) => void;
setEnabled(enabled: boolean): void;
release(): void;
discard(): void;
publish(): void;
rollback(): void;
destroy(): void;
addNode: (node: WorkflowNode, previousNodeId: string) => void;
@@ -95,7 +95,7 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
});
},
release: async () => {
publish: async () => {
if (!get().initialized) throw "Workflow not initialized yet";
const root = get().workflow.draft!;
@@ -121,7 +121,7 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
});
},
discard: async () => {
rollback: async () => {
if (!get().initialized) throw "Workflow not initialized yet";
const root = get().workflow.content!;