feat(plugin-workflow): add cancel action for execution (#3425)

* feat(plugin-workflow): add cancel action for execution

* feat(plugin-workflow): add cancel button in execution canvas

* fix(plugin-workflow-manual): fix canceled execution actions
This commit is contained in:
Junyi 2024-01-24 21:09:48 +08:00 committed by GitHub
parent dc61f10f78
commit fb11c82b79
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 237 additions and 78 deletions

View File

@ -31,7 +31,7 @@ import WorkflowPlugin, {
import { DetailsBlockProvider } from './instruction/DetailsBlockProvider'; import { DetailsBlockProvider } from './instruction/DetailsBlockProvider';
import { FormBlockProvider } from './instruction/FormBlockProvider'; import { FormBlockProvider } from './instruction/FormBlockProvider';
import { ManualFormType, manualFormTypes } from './instruction/SchemaConfig'; import { ManualFormType, manualFormTypes } from './instruction/SchemaConfig';
import { NAMESPACE } from '../locale'; import { NAMESPACE, useLang } from '../locale';
const nodeCollection = { const nodeCollection = {
title: `{{t("Task", { ns: "${NAMESPACE}" })}}`, title: `{{t("Task", { ns: "${NAMESPACE}" })}}`,
@ -211,6 +211,15 @@ const UserColumn = observer(
{ displayName: 'UserColumn' }, { displayName: 'UserColumn' },
); );
function UserJobStatusColumn(props) {
const record = useRecord();
const labelUnprocessed = useLang('Unprocessed');
if (record.execution.status && !record.status) {
return <Tag>{labelUnprocessed}</Tag>;
}
return props.children;
}
export const WorkflowTodo: React.FC & { Drawer: React.FC; Decorator: React.FC } = () => { export const WorkflowTodo: React.FC & { Drawer: React.FC; Decorator: React.FC } = () => {
return ( return (
<SchemaComponent <SchemaComponent
@ -218,6 +227,7 @@ export const WorkflowTodo: React.FC & { Drawer: React.FC; Decorator: React.FC }
NodeColumn, NodeColumn,
WorkflowColumn, WorkflowColumn,
UserColumn, UserColumn,
UserJobStatusColumn,
}} }}
schema={{ schema={{
type: 'void', type: 'void',
@ -326,6 +336,8 @@ export const WorkflowTodo: React.FC & { Drawer: React.FC; Decorator: React.FC }
title: `{{t("Status", { ns: "workflow" })}}`, title: `{{t("Status", { ns: "workflow" })}}`,
properties: { properties: {
status: { status: {
type: 'number',
'x-decorator': 'UserJobStatusColumn',
'x-component': 'CollectionField', 'x-component': 'CollectionField',
'x-read-pretty': true, 'x-read-pretty': true,
}, },
@ -400,16 +412,16 @@ function ActionBarProvider(props) {
const ManualActionStatusContext = createContext<number | null>(null); const ManualActionStatusContext = createContext<number | null>(null);
function ManualActionStatusProvider({ value, children }) { function ManualActionStatusProvider({ value, children }) {
const { userJob } = useFlowContext(); const { userJob, execution } = useFlowContext();
const button = useField(); const button = useField();
const buttonSchema = useFieldSchema(); const buttonSchema = useFieldSchema();
useEffect(() => { useEffect(() => {
if (userJob.status) { if (execution.status || userJob.status) {
button.disabled = true; button.disabled = true;
button.visible = userJob.status === value && userJob.result._ === buttonSchema.name; button.visible = userJob.status === value && userJob.result._ === buttonSchema.name;
} }
}, [userJob, value, button, buttonSchema.name]); }, [execution, userJob, value, button, buttonSchema.name]);
return <ManualActionStatusContext.Provider value={value}>{children}</ManualActionStatusContext.Provider>; return <ManualActionStatusContext.Provider value={value}>{children}</ManualActionStatusContext.Provider>;
} }
@ -420,12 +432,12 @@ function useSubmit() {
const { values, submit } = useForm(); const { values, submit } = useForm();
const buttonSchema = useFieldSchema(); const buttonSchema = useFieldSchema();
const { service } = useTableBlockContext(); const { service } = useTableBlockContext();
const { userJob } = useFlowContext(); const { userJob, execution } = useFlowContext();
const { name: actionKey } = buttonSchema; const { name: actionKey } = buttonSchema;
const { name: formKey } = buttonSchema.parent.parent; const { name: formKey } = buttonSchema.parent.parent;
return { return {
async run() { async run() {
if (userJob.status) { if (execution.status || userJob.status) {
return; return;
} }
await submit(); await submit();
@ -456,7 +468,7 @@ function FlowContextProvider(props) {
.resource('users_jobs') .resource('users_jobs')
.get?.({ .get?.({
filterByTk: id, filterByTk: id,
appends: ['node', 'workflow', 'workflow.nodes', 'execution', 'execution.jobs'], appends: ['node', 'job', 'workflow', 'workflow.nodes', 'execution', 'execution.jobs'],
}) })
.then(({ data }) => { .then(({ data }) => {
const { node, workflow: { nodes = [], ...workflow } = {}, execution, ...userJob } = data?.data ?? {}; const { node, workflow: { nodes = [], ...workflow } = {}, execution, ...userJob } = data?.data ?? {};
@ -517,18 +529,19 @@ function FlowContextProvider(props) {
} }
function useFormBlockProps() { function useFormBlockProps() {
const { userJob } = useFlowContext(); const { userJob, execution } = useFlowContext();
const record = useRecord(); const record = useRecord();
const { data: user } = useCurrentUserContext(); const { data: user } = useCurrentUserContext();
const { form } = useFormBlockContext(); const { form } = useFormBlockContext();
const pattern = userJob.status const pattern =
? record execution.status || userJob.status
? 'readPretty' ? record
: 'disabled' ? 'readPretty'
: user?.data?.id !== userJob.userId : 'disabled'
? 'disabled' : user?.data?.id !== userJob.userId
: 'editable'; ? 'disabled'
: 'editable';
useEffect(() => { useEffect(() => {
form?.setPattern(pattern); form?.setPattern(pattern);
@ -613,7 +626,7 @@ function Decorator({ params = {}, children }) {
pageSize: 20, pageSize: 20,
sort: ['-createdAt'], sort: ['-createdAt'],
...params, ...params,
appends: ['user', 'node', 'workflow'], appends: ['user', 'node', 'workflow', 'execution.status'],
except: ['node.config', 'workflow.config', 'workflow.options'], except: ['node.config', 'workflow.config', 'workflow.options'],
}, },
rowKey: 'id', rowKey: 'id',

View File

@ -26,5 +26,6 @@
"Update record form": "更新数据表单", "Update record form": "更新数据表单",
"Filter settings": "筛选设置", "Filter settings": "筛选设置",
"Workflow todos": "工作流待办", "Workflow todos": "工作流待办",
"Task node": "任务节点" "Task node": "任务节点",
"Unprocessed": "未处理"
} }

View File

@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { Breadcrumb, Dropdown, Result, Space, Spin, Tag } from 'antd'; import { Breadcrumb, Button, Dropdown, message, Modal, Result, Space, Spin, Tag, Tooltip } from 'antd';
import { import {
ActionContextProvider, ActionContextProvider,
@ -22,9 +22,10 @@ import { FlowContext, useFlowContext } from './FlowContext';
import { lang, NAMESPACE } from './locale'; import { lang, NAMESPACE } from './locale';
import useStyles from './style'; import useStyles from './style';
import { linkNodes } from './utils'; import { linkNodes } from './utils';
import { DownOutlined } from '@ant-design/icons'; import { DownOutlined, ExclamationCircleFilled, StopOutlined } from '@ant-design/icons';
import { StatusButton } from './components/StatusButton'; import { StatusButton } from './components/StatusButton';
import { getWorkflowDetailPath, getWorkflowExecutionsPath } from './constant'; import { getWorkflowDetailPath, getWorkflowExecutionsPath } from './constant';
import { useTranslation } from 'react-i18next';
function attachJobs(nodes, jobs: any[] = []): void { function attachJobs(nodes, jobs: any[] = []): void {
const nodesMap = new Map(); const nodesMap = new Map();
@ -210,16 +211,40 @@ function ExecutionsDropdown(props) {
} }
export function ExecutionCanvas() { export function ExecutionCanvas() {
const { t } = useTranslation();
const compile = useCompile(); const compile = useCompile();
const { data, loading } = useResourceActionContext(); const { data, loading, refresh } = useResourceActionContext();
const { setTitle } = useDocumentTitle(); const { setTitle } = useDocumentTitle();
const [viewJob, setViewJob] = useState(null); const [viewJob, setViewJob] = useState(null);
const app = useApp(); const app = useApp();
const apiClient = useAPIClient();
useEffect(() => { useEffect(() => {
const { workflow } = data?.data ?? {}; const { workflow } = data?.data ?? {};
setTitle?.(`${workflow?.title ? `${workflow.title} - ` : ''}${lang('Execution history')}`); setTitle?.(`${workflow?.title ? `${workflow.title} - ` : ''}${lang('Execution history')}`);
}, [data?.data]); }, [data?.data]);
const onCancel = useCallback(() => {
Modal.confirm({
title: lang('Cancel the execution'),
icon: <ExclamationCircleFilled />,
content: lang('Are you sure you want to cancel the execution?'),
onOk: () => {
apiClient
.resource('executions')
.cancel({
filterByTk: data?.data.id,
})
.then(() => {
message.success(t('Operation succeeded'));
refresh();
})
.catch((response) => {
console.error(response.data.error);
});
},
});
}, [data?.data]);
if (!data?.data) { if (!data?.data) {
if (loading) { if (loading) {
return <Spin />; return <Spin />;
@ -258,6 +283,11 @@ export function ExecutionCanvas() {
</header> </header>
<aside> <aside>
<Tag color={statusOption.color}>{compile(statusOption.label)}</Tag> <Tag color={statusOption.color}>{compile(statusOption.label)}</Tag>
{execution.status ? null : (
<Tooltip title={lang('Cancel the execution')}>
<Button type="link" danger onClick={onCancel} shape="circle" size="small" icon={<StopOutlined />} />
</Tooltip>
)}
<time>{str2moment(execution.updatedAt).format('YYYY-MM-DD HH:mm:ss')}</time> <time>{str2moment(execution.updatedAt).format('YYYY-MM-DD HH:mm:ss')}</time>
</aside> </aside>
</div> </div>

View File

@ -23,6 +23,7 @@ import { executionSchema } from './schemas/executions';
import useStyles from './style'; import useStyles from './style';
import { linkNodes } from './utils'; import { linkNodes } from './utils';
import { getWorkflowDetailPath } from './constant'; import { getWorkflowDetailPath } from './constant';
import { ExecutionStatusColumn } from './components/ExecutionStatus';
function ExecutionResourceProvider({ request, filter = {}, ...others }) { function ExecutionResourceProvider({ request, filter = {}, ...others }) {
const { workflow } = useFlowContext(); const { workflow } = useFlowContext();
@ -230,6 +231,7 @@ export function WorkflowCanvas() {
components={{ components={{
ExecutionResourceProvider, ExecutionResourceProvider,
ExecutionLink, ExecutionLink,
ExecutionStatusColumn,
}} }}
/> />
</ActionContextProvider> </ActionContextProvider>

View File

@ -6,7 +6,7 @@ import { ExecutionResourceProvider } from './ExecutionResourceProvider';
import { WorkflowLink } from './WorkflowLink'; import { WorkflowLink } from './WorkflowLink';
import OpenDrawer from './components/OpenDrawer'; import OpenDrawer from './components/OpenDrawer';
import { workflowSchema } from './schemas/workflows'; import { workflowSchema } from './schemas/workflows';
import { ExecutionStatusSelect } from './components/ExecutionStatusSelect'; import { ExecutionStatusSelect, ExecutionStatusColumn } from './components/ExecutionStatus';
import WorkflowPlugin from '.'; import WorkflowPlugin from '.';
export function WorkflowPane() { export function WorkflowPane() {
@ -23,6 +23,7 @@ export function WorkflowPane() {
ExecutionLink, ExecutionLink,
OpenDrawer, OpenDrawer,
ExecutionStatusSelect, ExecutionStatusSelect,
ExecutionStatusColumn,
}} }}
scope={{ scope={{
getTriggersOptions, getTriggersOptions,

View File

@ -0,0 +1,99 @@
import React, { useCallback } from 'react';
import { Button, Modal, Select, Tag, Tooltip, message } from 'antd';
import { ExclamationCircleFilled, StopOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { Action, css, useCompile, useRecord, useResourceActionContext, useResourceContext } from '@nocobase/client';
import { EXECUTION_STATUS, ExecutionStatusOptions, ExecutionStatusOptionsMap } from '../constants';
import { lang } from '../locale';
function LabelTag(props) {
const compile = useCompile();
const label = compile(props.label);
const onPreventMouseDown = useCallback((event: React.MouseEvent<HTMLSpanElement>) => {
event.preventDefault();
event.stopPropagation();
}, []);
const { color } = ExecutionStatusOptionsMap[props.value] ?? {};
return (
<Tag color={color} onMouseDown={onPreventMouseDown} closable={props.closable} onClose={props.onClose}>
{label}
</Tag>
);
}
function ExecutionStatusOption(props) {
const compile = useCompile();
return (
<>
<LabelTag {...props} />
{props.description ? <span>{compile(props.description)}</span> : null}
</>
);
}
export function ExecutionStatusSelect({ ...props }) {
const mode = props.multiple ? 'multiple' : null;
return (
<Select
// @ts-ignore
role="button"
data-testid={`select-${mode || 'single'}`}
{...props}
mode={mode}
optionLabelProp="label"
tagRender={LabelTag}
>
{ExecutionStatusOptions.filter((item) => Boolean(item.value) && item.value !== EXECUTION_STATUS.ABORTED).map(
(option) => (
<Select.Option key={option.value} {...option}>
<ExecutionStatusOption {...option} />
</Select.Option>
),
)}
</Select>
);
}
export function ExecutionStatusColumn(props) {
const { t } = useTranslation();
const { refresh } = useResourceActionContext();
const { resource } = useResourceContext();
const record = useRecord();
const onCancel = useCallback(() => {
Modal.confirm({
title: lang('Cancel the execution'),
icon: <ExclamationCircleFilled />,
content: lang('Are you sure you want to cancel the execution?'),
onOk: () => {
resource
.cancel({
filterByTk: record.id,
})
.then(() => {
message.success(t('Operation succeeded'));
refresh();
})
.catch((response) => {
console.error(response.data.error);
});
},
});
}, [record]);
return (
<div
className={css`
display: flex;
`}
>
{props.children}
{record.status ? null : (
<Tooltip title={lang('Cancel the execution')}>
<Button type="link" danger onClick={onCancel} shape="circle" size="small" icon={<StopOutlined />} />
</Tooltip>
)}
</div>
);
}

View File

@ -1,54 +0,0 @@
import { Select, Tag } from 'antd';
import React, { useCallback } from 'react';
import { useCompile } from '@nocobase/client';
import { EXECUTION_STATUS, ExecutionStatusOptions, ExecutionStatusOptionsMap } from '../constants';
function LabelTag(props) {
const compile = useCompile();
const label = compile(props.label);
const onPreventMouseDown = useCallback((event: React.MouseEvent<HTMLSpanElement>) => {
event.preventDefault();
event.stopPropagation();
}, []);
const { color } = ExecutionStatusOptionsMap[props.value] ?? {};
return (
<Tag color={color} onMouseDown={onPreventMouseDown} closable={props.closable} onClose={props.onClose}>
{label}
</Tag>
);
}
function ExecutionStatusOption(props) {
const compile = useCompile();
return (
<>
<LabelTag {...props} />
{props.description ? <span>{compile(props.description)}</span> : null}
</>
);
}
export function ExecutionStatusSelect({ ...props }) {
const mode = props.multiple ? 'multiple' : null;
return (
<Select
// @ts-ignore
role="button"
data-testid={`select-${mode || 'single'}`}
{...props}
mode={mode}
optionLabelProp="label"
tagRender={LabelTag}
>
{ExecutionStatusOptions.filter((item) => Boolean(item.value) && item.value !== EXECUTION_STATUS.ABORTED).map(
(option) => (
<Select.Option key={option.value} {...option}>
<ExecutionStatusOption {...option} />
</Select.Option>
),
)}
</Select>
);
}

View File

@ -11,10 +11,21 @@ import { getWorkflowDetailPath } from '../constant';
export const executionCollection = { export const executionCollection = {
name: 'executions', name: 'executions',
fields: [ fields: [
{
interface: 'id',
type: 'bigInt',
name: 'id',
uiSchema: {
type: 'number',
title: '{{t("ID")}}',
'x-component': 'Input',
'x-component-props': {},
'x-read-pretty': true,
} as ISchema,
},
{ {
interface: 'createdAt', interface: 'createdAt',
type: 'datetime', type: 'datetime',
// field: 'createdAt',
name: 'createdAt', name: 'createdAt',
uiSchema: { uiSchema: {
type: 'datetime', type: 'datetime',
@ -120,6 +131,18 @@ export const executionSchema = {
useDataSource: '{{ cm.useDataSourceFromRAC }}', useDataSource: '{{ cm.useDataSourceFromRAC }}',
}, },
properties: { properties: {
id: {
type: 'void',
'x-decorator': 'Table.Column.Decorator',
'x-component': 'Table.Column',
properties: {
id: {
type: 'number',
'x-component': 'CollectionField',
'x-read-pretty': true,
},
},
},
createdAt: { createdAt: {
type: 'void', type: 'void',
'x-decorator': 'Table.Column.Decorator', 'x-decorator': 'Table.Column.Decorator',
@ -151,9 +174,11 @@ export const executionSchema = {
type: 'void', type: 'void',
'x-decorator': 'Table.Column.Decorator', 'x-decorator': 'Table.Column.Decorator',
'x-component': 'Table.Column', 'x-component': 'Table.Column',
title: `{{t("Status", { ns: "${NAMESPACE}" })}}`,
properties: { properties: {
status: { status: {
type: 'number', type: 'number',
'x-decorator': 'ExecutionStatusColumn',
'x-component': 'CollectionField', 'x-component': 'CollectionField',
'x-read-pretty': true, 'x-read-pretty': true,
}, },
@ -173,7 +198,6 @@ export const executionSchema = {
properties: { properties: {
link: { link: {
type: 'void', type: 'void',
title: `{{t("Details", { ns: "${NAMESPACE}" })}}`,
'x-component': 'ExecutionLink', 'x-component': 'ExecutionLink',
}, },
}, },

View File

@ -116,6 +116,9 @@
"Rejected from a manual node.": "被人工节点拒绝继续。", "Rejected from a manual node.": "被人工节点拒绝继续。",
"General failed but should do another try.": "执行失败,需重试。", "General failed but should do another try.": "执行失败,需重试。",
"Cancel the execution": "取消执行",
"Are you sure you want to cancel the execution?": "确定要取消该执行吗?",
"Operations": "操作", "Operations": "操作",
"This node contains branches, deleting will also be preformed to them, are you sure?": "This node contains branches, deleting will also be preformed to them, are you sure?":
"节点包含分支,将同时删除其所有分支下的子节点,确定继续?", "节点包含分支,将同时删除其所有分支下的子节点,确定继续?",

View File

@ -1,6 +1,6 @@
import actions, { Context } from '@nocobase/actions'; import actions, { Context } from '@nocobase/actions';
import { Op } from '@nocobase/database'; import { Op } from '@nocobase/database';
import { EXECUTION_STATUS } from '../constants'; import { EXECUTION_STATUS, JOB_STATUS } from '../constants';
export async function destroy(context: Context, next) { export async function destroy(context: Context, next) {
context.action.mergeParams({ context.action.mergeParams({
@ -13,3 +13,43 @@ export async function destroy(context: Context, next) {
await actions.destroy(context, next); await actions.destroy(context, next);
} }
export async function cancel(context: Context, next) {
const { filterByTk } = context.action.params;
const ExecutionRepo = context.db.getRepository('executions');
const JobRepo = context.db.getRepository('jobs');
const execution = await ExecutionRepo.findOne({
filterByTk,
appends: ['jobs'],
});
if (!execution) {
return context.throw(404);
}
if (execution.status) {
return context.throw(400);
}
await context.db.sequelize.transaction(async (transaction) => {
await execution.update(
{
status: EXECUTION_STATUS.CANCELED,
},
{ transaction },
);
const pendingJobs = execution.jobs.filter((job) => job.status === JOB_STATUS.PENDING);
await JobRepo.update({
values: {
status: JOB_STATUS.CANCELED,
},
filter: {
id: pendingJobs.map((job) => job.id),
},
individualHooks: false,
transaction,
});
});
context.body = execution;
await next();
}