feat(ui): release & run workflow

This commit is contained in:
Fu Diwei 2025-01-01 16:53:52 +08:00
parent 5c1854948c
commit 6075cc5c95
17 changed files with 197 additions and 161 deletions

View File

@ -199,7 +199,7 @@ func createDeployer(target string, accessConfig string, deployConfig map[string]
ShellEnv: providerLocal.ShellEnvType(maps.GetValueAsString(deployConfig, "shellEnv")),
PreCommand: maps.GetValueAsString(deployConfig, "preCommand"),
PostCommand: maps.GetValueAsString(deployConfig, "postCommand"),
OutputFormat: providerLocal.OutputFormatType(maps.GetValueOrDefaultAsString(deployConfig, "format", "PEM")),
OutputFormat: providerLocal.OutputFormatType(maps.GetValueOrDefaultAsString(deployConfig, "format", string(providerLocal.OUTPUT_FORMAT_PEM))),
OutputCertPath: maps.GetValueAsString(deployConfig, "certPath"),
OutputKeyPath: maps.GetValueAsString(deployConfig, "keyPath"),
PfxPassword: maps.GetValueAsString(deployConfig, "pfxPassword"),
@ -259,7 +259,7 @@ func createDeployer(target string, accessConfig string, deployConfig map[string]
SshKeyPassphrase: access.KeyPassphrase,
PreCommand: maps.GetValueAsString(deployConfig, "preCommand"),
PostCommand: maps.GetValueAsString(deployConfig, "postCommand"),
OutputFormat: providerSSH.OutputFormatType(maps.GetValueOrDefaultAsString(deployConfig, "format", "PEM")),
OutputFormat: providerSSH.OutputFormatType(maps.GetValueOrDefaultAsString(deployConfig, "format", string(providerSSH.OUTPUT_FORMAT_PEM))),
OutputCertPath: maps.GetValueAsString(deployConfig, "certPath"),
OutputKeyPath: maps.GetValueAsString(deployConfig, "keyPath"),
PfxPassword: maps.GetValueAsString(deployConfig, "pfxPassword"),

View File

@ -3,8 +3,8 @@ import { useTranslation } from "react-i18next";
import { useRequest } from "ahooks";
import { Button, Form, Input, message, notification, Skeleton } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
import { ClientResponseError } from "pocketbase";
import { z } from "zod";
import Show from "@/components/Show";
import { useAntdForm } from "@/hooks";

View File

@ -353,17 +353,14 @@ const FormFieldDomainsModalForm = ({
setModel({ domains: data?.split(MULTIPLE_INPUT_DELIMITER) });
}, [data]);
const handleFinish = useCallback(
(values: z.infer<typeof formSchema>) => {
onFinish?.(
values.domains
.map((e) => e.trim())
.filter((e) => !!e)
.join(MULTIPLE_INPUT_DELIMITER)
);
},
[onFinish]
);
const handleFormFinish = (values: z.infer<typeof formSchema>) => {
onFinish?.(
values.domains
.map((e) => e.trim())
.filter((e) => !!e)
.join(MULTIPLE_INPUT_DELIMITER)
);
};
return (
<ModalForm
@ -375,7 +372,7 @@ const FormFieldDomainsModalForm = ({
trigger={trigger}
validateTrigger="onSubmit"
width={480}
onFinish={handleFinish}
onFinish={handleFormFinish}
>
<Form.Item name="domains" rules={[formRule]}>
<MultipleInput placeholder={t("workflow_node.apply.form.domains.multiple_input_modal.placeholder")} />
@ -400,17 +397,14 @@ const FormFieldNameserversModalForm = ({ data, trigger, onFinish }: { data: stri
setModel({ nameservers: data?.split(MULTIPLE_INPUT_DELIMITER) });
}, [data]);
const handleFinish = useCallback(
(values: z.infer<typeof formSchema>) => {
onFinish?.(
values.nameservers
.map((e) => e.trim())
.filter((e) => !!e)
.join(MULTIPLE_INPUT_DELIMITER)
);
},
[onFinish]
);
const handleFormFinish = (values: z.infer<typeof formSchema>) => {
onFinish?.(
values.nameservers
.map((e) => e.trim())
.filter((e) => !!e)
.join(MULTIPLE_INPUT_DELIMITER)
);
};
return (
<ModalForm
@ -422,7 +416,7 @@ const FormFieldNameserversModalForm = ({ data, trigger, onFinish }: { data: stri
trigger={trigger}
validateTrigger="onSubmit"
width={480}
onFinish={handleFinish}
onFinish={handleFormFinish}
>
<Form.Item name="nameservers" rules={[formRule]}>
<MultipleInput placeholder={t("workflow_node.apply.form.nameservers.multiple_input_modal.placeholder")} />

View File

@ -6,9 +6,9 @@ import { z } from "zod";
import Show from "@/components/Show";
const FORMAT_PEM = "pem" as const;
const FORMAT_PFX = "pfx" as const;
const FORMAT_JKS = "jks" as const;
const FORMAT_PEM = "PEM" as const;
const FORMAT_PFX = "PFX" as const;
const FORMAT_JKS = "JKS" as const;
const SHELLENV_SH = "sh" as const;
const SHELLENV_CMD = "cmd" as const;

View File

@ -6,9 +6,9 @@ import { z } from "zod";
import Show from "@/components/Show";
const FORMAT_PEM = "pem" as const;
const FORMAT_PFX = "pfx" as const;
const FORMAT_JKS = "jks" as const;
const FORMAT_PEM = "PEM" as const;
const FORMAT_PFX = "PFX" as const;
const FORMAT_JKS = "JKS" as const;
const DeployNodeFormSSHFields = () => {
const { t } = useTranslation();

View File

@ -32,28 +32,11 @@ const WorkflowRunDetailDrawer = ({ data, loading, trigger, ...props }: WorkflowR
<Drawer closable destroyOnClose open={open} loading={loading} placement="right" title={`runlog-${data?.id}`} width={640} onClose={() => setOpen(false)}>
<Show when={!!data}>
<Show when={data!.succeed}>
<Alert
showIcon
type="success"
message={
<>
<Typography.Text type="success">{t("workflow_run.props.status.succeeded")}</Typography.Text>
</>
}
/>
<Alert showIcon type="success" message={<Typography.Text type="success">{t("workflow_run.props.status.succeeded")}</Typography.Text>} />
</Show>
<Show when={!!data!.error}>
<Alert
showIcon
type="error"
message={
<>
<Typography.Text type="danger">{t("workflow_run.props.status.failed")}</Typography.Text>
</>
}
description={data!.error}
/>
<Alert showIcon type="error" message={<Typography.Text type="danger">{t("workflow_run.props.status.failed")}</Typography.Text>} />
</Show>
<div className="mt-4 p-4 bg-black text-stone-200 rounded-md">

View File

@ -54,12 +54,12 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
</Space>
);
} else {
<Tooltip title={record.error}>
return (
<Space>
<CloseCircleOutlinedIcon style={{ color: themeToken.colorError }} />
<Typography.Text type="danger">{t("workflow_run.props.status.failed")}</Typography.Text>
</Space>
</Tooltip>;
);
}
},
},

View File

@ -362,20 +362,20 @@ export const getWorkflowOutputBeforeId = (node: WorkflowNode | WorkflowBranchNod
return output;
};
export const allNodesValidated = (node: WorkflowNode | WorkflowBranchNode): boolean => {
let current = node;
export const isAllNodesValidated = (node: WorkflowNode | WorkflowBranchNode): 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 (!allNodesValidated(branch)) {
if (!isAllNodesValidated(branch)) {
return false;
}
}
}
current = current.next as WorkflowNode;
current = current.next;
}
return true;
};

View File

@ -7,11 +7,14 @@
"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",
"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.execute": "Run",
"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.failed.uncompleted": "Please complete the orchestration and publish the changes first",
"workflow.props.name": "Name",
"workflow.props.description": "Description",
@ -35,13 +38,6 @@
"workflow.detail.baseinfo.form.description.placeholder": "Please enter description",
"workflow.common.certificate.label": "Certificate",
"workflow.detail.action.save": "Save updates",
"workflow.detail.action.save.failed": "Save failed",
"workflow.detail.action.save.failed.uncompleted": "Please complete the orchestration and publish the changes first",
"workflow.detail.action.run": "Run",
"workflow.detail.action.run.failed": "Run failed",
"workflow.detail.action.run.success": "Run success",
"workflow.detail.action.running": "Running",
"workflow.node.setting.label": "Setting Node",
"workflow.node.delete.label": "Delete Node",
"workflow.node.addBranch.label": "Add Branch",

View File

@ -11,8 +11,10 @@
"workflow.action.discard.confirm": "确定要撤销更改并回退到最近一次发布的版本吗?",
"workflow.action.release": "发布更改",
"workflow.action.release.confirm": "确定要发布更改吗?",
"workflow.action.execute": "执行",
"workflow.action.execute.confirm": "确定要立即执行此工作流吗?",
"workflow.action.release.failed.uncompleted": "请先完成流程编排",
"workflow.action.run": "执行",
"workflow.action.run.confirm": "存在未发布的更改,确定要按最近一次发布的版本来执行此工作流吗?",
"workflow.action.enable.failed.uncompleted": "请先完成流程编排并发布更改",
"workflow.props.name": "名称",
"workflow.props.description": "描述",
@ -36,13 +38,6 @@
"workflow.detail.baseinfo.form.description.placeholder": "请输入工作流描述",
"workflow.common.certificate.label": "证书",
"workflow.detail.action.save": "保存变更",
"workflow.detail.action.save.failed": "保存失败",
"workflow.detail.action.save.failed.uncompleted": "请先完成流程编排并发布更改",
"workflow.detail.action.run": "立即执行",
"workflow.detail.action.run.failed": "执行失败",
"workflow.detail.action.run.success": "执行成功",
"workflow.detail.action.running": "正在执行",
"workflow.node.setting.label": "设置节点",
"workflow.node.delete.label": "删除节点",
"workflow.node.addBranch.label": "添加分支",

View File

@ -28,7 +28,7 @@ const Login = () => {
onSubmit: async (values) => {
try {
await getPocketBase().admins.authWithPassword(values.username, values.password);
navigage("/");
await navigage("/");
} catch (err) {
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
}

View File

@ -1,14 +1,18 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Button, Card, Dropdown, Form, Input, message, Modal, notification, Tabs, Typography } from "antd";
import { useDeepCompareEffect } from "ahooks";
import { Button, Card, Dropdown, Form, Input, message, Modal, notification, Space, Tabs, Typography } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { PageHeader } from "@ant-design/pro-components";
import {
CaretRightOutlined as CaretRightOutlinedIcon,
DeleteOutlined as DeleteOutlinedIcon,
EllipsisOutlined as EllipsisOutlinedIcon,
UndoOutlined as UndoOutlinedIcon,
} from "@ant-design/icons";
import { ClientResponseError } from "pocketbase";
import { isEqual } from "radash";
import { z } from "zod";
import Show from "@/components/Show";
@ -18,9 +22,10 @@ import NodeRender from "@/components/workflow/NodeRender";
import WorkflowRuns from "@/components/workflow/run/WorkflowRuns";
import WorkflowProvider from "@/components/workflow/WorkflowProvider";
import { useAntdForm, useZustandShallowSelector } from "@/hooks";
import { allNodesValidated, type WorkflowModel, type WorkflowNode } from "@/domain/workflow";
import { isAllNodesValidated, type WorkflowModel, type WorkflowNode } from "@/domain/workflow";
import { useWorkflowStore } from "@/stores/workflow";
import { remove as removeWorkflow } from "@/repository/workflow";
import { run as runWorkflow } from "@/api/workflow";
import { getErrMsg } from "@/utils/error";
const WorkflowDetail = () => {
@ -33,16 +38,17 @@ const WorkflowDetail = () => {
const [notificationApi, NotificationContextHolder] = notification.useNotification();
const { id: workflowId } = useParams();
const { workflow, init, setBaseInfo, switchEnable } = useWorkflowStore(useZustandShallowSelector(["workflow", "init", "setBaseInfo", "switchEnable"]));
const { workflow, init, save, setBaseInfo, switchEnable } = useWorkflowStore(
useZustandShallowSelector(["workflow", "init", "save", "setBaseInfo", "switchEnable"])
);
useEffect(() => {
// TODO: loading
init(workflowId);
}, [workflowId, init]);
const [tabValue, setTabValue] = useState<"orchestration" | "runs">("orchestration");
// const [running, setRunning] = useState(false);
const elements = useMemo(() => {
const workflowNodes = useMemo(() => {
let current = workflow.draft as WorkflowNode;
const elements: JSX.Element[] = [];
@ -58,6 +64,16 @@ const WorkflowDetail = () => {
return elements;
}, [workflow]);
const [workflowRunning, setWorkflowRunning] = useState(false);
const [allowDiscard, setAllowDiscard] = useState(false);
const [allowRelease, setAllowRelease] = useState(false);
useDeepCompareEffect(() => {
const hasChanges = workflow.hasDraft! || !isEqual(workflow.draft, workflow.content);
setAllowDiscard(hasChanges && !workflowRunning);
setAllowRelease(hasChanges && !workflowRunning);
}, [workflow, workflowRunning]);
const handleBaseInfoFormFinish = async (values: Pick<WorkflowModel, "name" | "description">) => {
try {
await setBaseInfo(values.name!, values.description!);
@ -69,10 +85,11 @@ const WorkflowDetail = () => {
};
const handleEnableChange = () => {
if (!workflow.enabled && !allNodesValidated(workflow.content!)) {
messageApi.warning(t("workflow.detail.action.save.failed.uncompleted"));
if (!workflow.enabled && !isAllNodesValidated(workflow.content!)) {
messageApi.warning(t("workflow.action.enable.failed.uncompleted"));
return;
}
switchEnable();
};
@ -84,7 +101,7 @@ const WorkflowDetail = () => {
try {
const resp: boolean = await removeWorkflow(workflow);
if (resp) {
navigate("/workflows");
navigate("/workflows", { replace: true });
}
} catch (err) {
console.error(err);
@ -94,29 +111,59 @@ const WorkflowDetail = () => {
});
};
// TODO: 发布更改 撤销更改 立即执行
// const handleWorkflowSaveClick = () => {
// if (!allNodesValidated(workflow.draft as WorkflowNode)) {
// messageApi.warning(t("workflow.detail.action.save.failed.uncompleted"));
// return;
// }
// save();
// };
const handleDiscardClick = () => {
alert("TODO");
};
// const handleRunClick = async () => {
// if (running) return;
const handleReleaseClick = () => {
if (!isAllNodesValidated(workflow.draft!)) {
messageApi.warning(t("workflow.action.release.failed.uncompleted"));
return;
}
// setRunning(true);
// try {
// await runWorkflow(workflow.id as string);
// messageApi.success(t("workflow.detail.action.run.success"));
// } catch (err) {
// console.error(err);
// messageApi.warning(t("workflow.detail.action.run.failed"));
// } finally {
// setRunning(false);
// }
// };
save();
messageApi.success(t("common.text.operation_succeeded"));
};
const handleRunClick = () => {
if (!workflow.enabled) {
alert("TODO: 暂时只支持执行已启用的工作流");
return;
}
const { promise, resolve, reject } = Promise.withResolvers();
if (workflow.hasDraft) {
modalApi.confirm({
title: t("workflow.action.run"),
content: t("workflow.action.run.confirm"),
onOk: () => resolve(void 0),
onCancel: () => reject(),
});
} else {
resolve(void 0);
}
// TODO: 异步执行
promise.then(async () => {
setWorkflowRunning(true);
try {
await runWorkflow(workflowId!);
messageApi.warning(t("common.text.operation_succeeded"));
} catch (err) {
if (err instanceof ClientResponseError && err.isAbort) {
return;
}
console.error(err);
messageApi.warning(t("common.text.operation_failed"));
} finally {
setWorkflowRunning(false);
}
});
};
return (
<div>
@ -175,17 +222,37 @@ const WorkflowDetail = () => {
<Show when={tabValue === "orchestration"}>
<div className="relative">
<div className="flex flex-col items-center py-12 pr-48">
<WorkflowProvider>{elements}</WorkflowProvider>
<WorkflowProvider>{workflowNodes}</WorkflowProvider>
</div>
<div className="absolute top-0 right-0 z-[1]">
<Button.Group>
<Button onClick={() => alert("TODO")}>{t("workflow.action.discard")}</Button>
<Button onClick={() => alert("TODO")}>{t("workflow.action.release")}</Button>
<Button type="primary" onClick={() => alert("TODO")}>
<CaretRightOutlinedIcon />
{t("workflow.action.execute")}
<Space>
<Button icon={<CaretRightOutlinedIcon />} loading={workflowRunning} type="primary" onClick={handleRunClick}>
{t("workflow.action.run")}
</Button>
</Button.Group>
<Button.Group>
<Button color="primary" disabled={!allowRelease} variant="outlined" onClick={handleReleaseClick}>
{t("workflow.action.release")}
</Button>
<Dropdown
menu={{
items: [
{
key: "discard",
disabled: !allowDiscard,
label: t("workflow.action.discard"),
icon: <UndoOutlinedIcon />,
onClick: handleDiscardClick,
},
],
}}
trigger={["click"]}
>
<Button color="primary" icon={<EllipsisOutlinedIcon />} variant="outlined" />
</Dropdown>
</Button.Group>
</Space>
</div>
</div>
</Show>
@ -237,7 +304,7 @@ const WorkflowBaseInfoModalForm = ({
},
});
const handleFinish = async () => {
const handleFormFinish = async () => {
return formApi.submit();
};
@ -252,7 +319,7 @@ const WorkflowBaseInfoModalForm = ({
trigger={trigger}
width={480}
{...formProps}
onFinish={handleFinish}
onFinish={handleFormFinish}
>
<Form.Item name="name" label={t("workflow.detail.baseinfo.form.name.label")} rules={[formRule]}>
<Input placeholder={t("workflow.detail.baseinfo.form.name.placeholder")} />
@ -264,4 +331,5 @@ const WorkflowBaseInfoModalForm = ({
</ModalForm>
);
};
export default WorkflowDetail;

View File

@ -25,7 +25,7 @@ import { DeleteOutlined as DeleteOutlinedIcon, EditOutlined as EditOutlinedIcon,
import dayjs from "dayjs";
import { ClientResponseError } from "pocketbase";
import { allNodesValidated, type WorkflowModel } from "@/domain/workflow";
import { isAllNodesValidated, type WorkflowModel } from "@/domain/workflow";
import { list as listWorkflow, remove as removeWorkflow, save as saveWorkflow } from "@/repository/workflow";
import { getErrMsg } from "@/utils/error";
@ -241,8 +241,8 @@ const WorkflowList = () => {
const handleEnabledChange = async (workflow: WorkflowModel) => {
try {
if (!workflow.enabled && !allNodesValidated(workflow.content!)) {
messageApi.warning(t("workflow.detail.action.save.failed.uncompleted"));
if (!workflow.enabled && !isAllNodesValidated(workflow.content!)) {
messageApi.warning(t("workflow.action.enable.failed.uncompleted"));
return;
}

View File

@ -23,6 +23,19 @@ export const useAccessStore = create<AccessState>((set) => {
loading: false,
loadedAtOnce: false,
fetchAccesses: async () => {
fetcher ??= listAccess();
try {
set({ loading: true });
const accesses = await fetcher;
set({ accesses: accesses ?? [], loadedAtOnce: true });
} finally {
fetcher = null;
set({ loading: false });
}
},
createAccess: async (access) => {
const record = await saveAccess(access);
set(
@ -58,18 +71,5 @@ export const useAccessStore = create<AccessState>((set) => {
return access as AccessModel;
},
fetchAccesses: async () => {
fetcher ??= listAccess();
try {
set({ loading: true });
const accesses = await fetcher;
set({ accesses: accesses ?? [], loadedAtOnce: true });
} finally {
fetcher = null;
set({ loading: false });
}
},
};
});

View File

@ -24,6 +24,19 @@ export const useContactStore = create<ContactState>((set, get) => {
loading: false,
loadedAtOnce: false,
fetchEmails: async () => {
fetcher ??= getSettings<EmailsSettingsContent>(SETTINGS_NAMES.EMAILS);
try {
set({ loading: true });
settings = await fetcher;
set({ emails: settings.content.emails?.sort() ?? [], loadedAtOnce: true });
} finally {
fetcher = null;
set({ loading: false });
}
},
setEmails: async (emails) => {
settings ??= await getSettings<EmailsSettingsContent>(SETTINGS_NAMES.EMAILS);
settings = await saveSettings<EmailsSettingsContent>({
@ -58,18 +71,5 @@ export const useContactStore = create<ContactState>((set, get) => {
});
get().setEmails(emails);
},
fetchEmails: async () => {
fetcher ??= getSettings<EmailsSettingsContent>(SETTINGS_NAMES.EMAILS);
try {
set({ loading: true });
settings = await fetcher;
set({ emails: settings.content.emails?.sort() ?? [], loadedAtOnce: true });
} finally {
fetcher = null;
set({ loading: false });
}
},
};
});

View File

@ -23,6 +23,19 @@ export const useNotifyChannelStore = create<NotifyChannelState>((set, get) => {
loading: false,
loadedAtOnce: false,
fetchChannels: async () => {
fetcher ??= getSettings<NotifyChannelsSettingsContent>(SETTINGS_NAMES.NOTIFY_CHANNELS);
try {
set({ loading: true });
settings = await fetcher;
set({ channels: settings.content ?? {}, loadedAtOnce: true });
} finally {
fetcher = null;
set({ loading: false });
}
},
setChannel: async (channel, config) => {
settings ??= await getSettings<NotifyChannelsSettingsContent>(SETTINGS_NAMES.NOTIFY_CHANNELS);
return get().setChannels(
@ -47,18 +60,5 @@ export const useNotifyChannelStore = create<NotifyChannelState>((set, get) => {
})
);
},
fetchChannels: async () => {
fetcher ??= getSettings<NotifyChannelsSettingsContent>(SETTINGS_NAMES.NOTIFY_CHANNELS);
try {
set({ loading: true });
settings = await fetcher;
set({ channels: settings.content ?? {}, loadedAtOnce: true });
} finally {
fetcher = null;
set({ loading: false });
}
},
};
});

View File

@ -5,9 +5,9 @@
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
"DOM.Iterable",
"ESNext",
],
"module": "ESNext",
"skipLibCheck": true,