feat(plugin-workflow): allow to configure auto delete execution in history (#2423)

* feat(plugin-workflow): allow to configure auto delete execution

* fix(plugin-workflow): fix locale
This commit is contained in:
Junyi 2023-08-10 15:18:07 +07:00 committed by GitHub
parent 9881d69def
commit fa43d9c870
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 309 additions and 57 deletions

View File

@ -342,7 +342,9 @@ export const useCreateAction = () => {
field.data.loading = false; field.data.loading = false;
refresh(); refresh();
} catch (error) { } catch (error) {
field.data.loading=false; if (field.data) {
field.data.loading = false;
}
} }
}, },
}; };

View File

@ -15,6 +15,7 @@ import { lang } from './locale';
import { instructions } from './nodes'; import { instructions } from './nodes';
import { workflowSchema } from './schemas/workflows'; import { workflowSchema } from './schemas/workflows';
import { triggers } from './triggers'; import { triggers } from './triggers';
import { ExecutionStatusSelect } from './components/ExecutionStatusSelect';
// registerField(expressionField.group, 'expression', expressionField); // registerField(expressionField.group, 'expression', expressionField);
@ -35,6 +36,7 @@ function WorkflowPane() {
ExecutionResourceProvider, ExecutionResourceProvider,
ExecutionLink, ExecutionLink,
OpenDrawer, OpenDrawer,
ExecutionStatusSelect,
}} }}
/> />
</Card> </Card>

View File

@ -0,0 +1,44 @@
import React, { useCallback } from 'react';
import { Select, Tag } from 'antd';
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 }) {
return (
<Select {...props} mode={props.multiple ? 'multiple' : null} 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

@ -20,14 +20,54 @@ export const EXECUTION_STATUS = {
}; };
export const ExecutionStatusOptions = [ export const ExecutionStatusOptions = [
{ value: EXECUTION_STATUS.QUEUEING, label: `{{t("Queueing", { ns: "${NAMESPACE}" })}}`, color: 'blue' }, {
{ value: EXECUTION_STATUS.STARTED, label: `{{t("On going", { ns: "${NAMESPACE}" })}}`, color: 'gold' }, value: EXECUTION_STATUS.QUEUEING,
{ value: EXECUTION_STATUS.RESOLVED, label: `{{t("Resolved", { ns: "${NAMESPACE}" })}}`, color: 'green' }, label: `{{t("Queueing", { ns: "${NAMESPACE}" })}}`,
{ value: EXECUTION_STATUS.FAILED, label: `{{t("Failed", { ns: "${NAMESPACE}" })}}`, color: 'red' }, color: 'blue',
{ value: EXECUTION_STATUS.ERROR, label: `{{t("Error", { ns: "${NAMESPACE}" })}}`, color: 'red' }, description: `{{t("Triggered but still waiting in queue to execute.", { ns: "${NAMESPACE}" })}}`,
{ value: EXECUTION_STATUS.ABORTED, label: `{{t("Aborted", { ns: "${NAMESPACE}" })}}`, color: 'red' }, },
{ value: EXECUTION_STATUS.CANCELED, label: `{{t("Canceled", { ns: "${NAMESPACE}" })}}`, color: 'volcano' }, {
{ value: EXECUTION_STATUS.REJECTED, label: `{{t("Rejected", { ns: "${NAMESPACE}" })}}`, color: 'volcano' }, value: EXECUTION_STATUS.STARTED,
label: `{{t("On going", { ns: "${NAMESPACE}" })}}`,
color: 'gold',
description: `{{t("Started and executing, maybe waiting for an async callback (manual, delay etc.).", { ns: "${NAMESPACE}" })}}`,
},
{
value: EXECUTION_STATUS.RESOLVED,
label: `{{t("Resolved", { ns: "${NAMESPACE}" })}}`,
color: 'green',
description: `{{t("Successfully finished.", { ns: "${NAMESPACE}" })}}`,
},
{
value: EXECUTION_STATUS.FAILED,
label: `{{t("Failed", { ns: "${NAMESPACE}" })}}`,
color: 'red',
description: `{{t("Failed to satisfy node configurations.", { ns: "${NAMESPACE}" })}}`,
},
{
value: EXECUTION_STATUS.ERROR,
label: `{{t("Error", { ns: "${NAMESPACE}" })}}`,
color: 'red',
description: `{{t("Some node meets error.", { ns: "${NAMESPACE}" })}}`,
},
{
value: EXECUTION_STATUS.ABORTED,
label: `{{t("Aborted", { ns: "${NAMESPACE}" })}}`,
color: 'red',
description: `{{t("Running of some node was aborted by program flow.", { ns: "${NAMESPACE}" })}}`,
},
{
value: EXECUTION_STATUS.CANCELED,
label: `{{t("Canceled", { ns: "${NAMESPACE}" })}}`,
color: 'volcano',
description: `{{t("Manually canceled whole execution when waiting.", { ns: "${NAMESPACE}" })}}`,
},
{
value: EXECUTION_STATUS.REJECTED,
label: `{{t("Rejected", { ns: "${NAMESPACE}" })}}`,
color: 'volcano',
description: `{{t("Rejected from a manual node.", { ns: "${NAMESPACE}" })}}`,
},
]; ];
export const ExecutionStatusOptionsMap = ExecutionStatusOptions.reduce( export const ExecutionStatusOptionsMap = ExecutionStatusOptions.reduce(

View File

@ -74,9 +74,49 @@ const collection = {
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
} as ISchema, } as ISchema,
}, },
{
type: 'object',
name: 'options',
},
], ],
}; };
const workflowFieldset = {
title: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
type: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
description: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
options: {
type: 'object',
'x-component': 'fieldset',
properties: {
useTransaction: {
type: 'boolean',
title: `{{ t("Use transaction", { ns: "${NAMESPACE}" }) }}`,
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
},
deleteExecutionOnStatus: {
type: 'array',
title: `{{ t("Auto delete history when execution is on end status", { ns: "${NAMESPACE}" }) }}`,
'x-decorator': 'FormItem',
'x-component': 'ExecutionStatusSelect',
'x-component-props': {
multiple: true,
},
},
},
},
};
export const workflowSchema: ISchema = { export const workflowSchema: ISchema = {
type: 'void', type: 'void',
properties: { properties: {
@ -132,18 +172,7 @@ export const workflowSchema: ISchema = {
}, },
title: '{{t("Add new")}}', title: '{{t("Add new")}}',
properties: { properties: {
title: { ...workflowFieldset,
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
type: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
description: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
footer: { footer: {
type: 'void', type: 'void',
'x-component': 'Action.Drawer.Footer', 'x-component': 'Action.Drawer.Footer',
@ -283,7 +312,7 @@ export const workflowSchema: ISchema = {
split: '|', split: '|',
}, },
properties: { properties: {
config: { view: {
type: 'void', type: 'void',
'x-component': 'WorkflowLink', 'x-component': 'WorkflowLink',
}, },
@ -304,18 +333,7 @@ export const workflowSchema: ISchema = {
}, },
title: '{{ t("Edit") }}', title: '{{ t("Edit") }}',
properties: { properties: {
title: { ...workflowFieldset,
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
enabled: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
description: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
footer: { footer: {
type: 'void', type: 'void',
'x-component': 'Action.Drawer.Footer', 'x-component': 'Action.Drawer.Footer',
@ -403,18 +421,18 @@ export const workflowSchema: ISchema = {
}, },
}, },
}, },
// delete: { delete: {
// type: 'void', type: 'void',
// title: '{{ t("Delete") }}', title: '{{ t("Delete") }}',
// 'x-component': 'Action.Link', 'x-component': 'Action.Link',
// 'x-component-props': { 'x-component-props': {
// confirm: { confirm: {
// title: "{{t('Delete record')}}", title: "{{t('Delete record')}}",
// content: "{{t('Are you sure you want to delete it?')}}", content: "{{t('Are you sure you want to delete it?')}}",
// }, },
// useAction: '{{ cm.useDestroyActionAndRefreshCM }}', useAction: '{{ cm.useDestroyActionAndRefreshCM }}',
// }, },
// }, },
}, },
}, },
}, },

View File

@ -65,7 +65,7 @@ const useStyles = createStyles(({ css, token }) => {
text-align: right; text-align: right;
time { time {
width: 14em; width: 12em;
color: ${token.colorText}; color: ${token.colorText};
font-size: 80%; font-size: 80%;
} }

View File

@ -18,6 +18,8 @@ export default {
'Delete a main version will cause all other revisions to be deleted too.': '删除主版本将导致其他版本一并被删除。', 'Delete a main version will cause all other revisions to be deleted too.': '删除主版本将导致其他版本一并被删除。',
Loading: '加载中', Loading: '加载中',
'Load failed': '加载失败', 'Load failed': '加载失败',
'Use transaction': '启用事务',
'Auto delete history when execution is on end status': '执行结束后自动删除对应状态的历史记录',
Trigger: '触发器', Trigger: '触发器',
'Trigger variables': '触发器变量', 'Trigger variables': '触发器变量',
'Trigger data': '触发数据', 'Trigger data': '触发数据',
@ -103,6 +105,16 @@ export default {
Canceled: '已取消', Canceled: '已取消',
Rejected: '已拒绝', Rejected: '已拒绝',
'Triggered but still waiting in queue to execute.': '已触发但仍在队列中等待执行。',
'Started and executing, maybe waiting for an async callback (manual, delay etc.).':
'已开始执行,可能在等待异步回调(人工、延时等)。',
'Successfully finished.': '成功完成。',
'Failed to satisfy node configurations.': '未满足节点配置造成的失败。',
'Some node meets error.': '某个节点出错。',
'Running of some node was aborted by program flow.': '某个节点被程序流程终止。',
'Manually canceled whole execution when waiting.': '等待时被手动取消整个执行。',
'Rejected from a manual node.': '被人工节点拒绝继续。',
'Continue the process': '继续流程', 'Continue the process': '继续流程',
'Terminate the process': '终止流程', 'Terminate the process': '终止流程',
'Save temporarily': '暂存', 'Save temporarily': '暂存',

View File

@ -253,7 +253,6 @@ export default class WorkflowPlugin extends Plugin {
context, context,
key: workflow.key, key: workflow.key,
status: EXECUTION_STATUS.QUEUEING, status: EXECUTION_STATUS.QUEUEING,
useTransaction: workflow.useTransaction,
}, },
{ transaction }, { transaction },
); );
@ -350,6 +349,9 @@ export default class WorkflowPlugin extends Plugin {
this.getLogger(execution.workflowId).info( this.getLogger(execution.workflowId).info(
`execution (${execution.id}) finished with status: ${execution.status}`, `execution (${execution.id}) finished with status: ${execution.status}`,
); );
if (execution.status && execution.workflow.options?.deleteExecutionOnStatus?.includes(execution.status)) {
await execution.destroy();
}
} catch (err) { } catch (err) {
this.getLogger(execution.workflowId).error(`execution (${execution.id}) error: ${err.message}`, err); this.getLogger(execution.workflowId).error(`execution (${execution.id}) error: ${err.message}`, err);
} }

View File

@ -31,7 +31,10 @@ export default class Processor {
jobsMap = new Map<number, JobModel>(); jobsMap = new Map<number, JobModel>();
jobsMapByNodeId: { [key: number]: any } = {}; jobsMapByNodeId: { [key: number]: any } = {};
constructor(public execution: ExecutionModel, public options: ProcessorOptions) { constructor(
public execution: ExecutionModel,
public options: ProcessorOptions,
) {
this.logger = options.plugin.getLogger(execution.workflowId); this.logger = options.plugin.getLogger(execution.workflowId);
} }
@ -63,7 +66,7 @@ export default class Processor {
} }
private async getTransaction() { private async getTransaction() {
if (!this.execution.useTransaction) { if (!this.execution.workflow.options?.useTransaction) {
return; return;
} }
@ -76,15 +79,15 @@ export default class Processor {
} }
public async prepare() { public async prepare() {
const { execution } = this;
if (!execution.workflow) {
execution.workflow = await execution.getWorkflow();
}
const transaction = await this.getTransaction(); const transaction = await this.getTransaction();
this.transaction = transaction; this.transaction = transaction;
const { execution } = this; const nodes = await execution.workflow.getNodes();
if (!execution.workflow) {
execution.workflow = await execution.getWorkflow({ transaction });
}
const nodes = await execution.workflow.getNodes({ transaction });
this.makeNodes(nodes); this.makeNodes(nodes);

View File

@ -395,4 +395,96 @@ describe('workflow > Plugin', () => {
expect(e2.status).toBe(EXECUTION_STATUS.QUEUEING); expect(e2.status).toBe(EXECUTION_STATUS.QUEUEING);
}); });
}); });
describe('options.deleteExecutionOnStatus', () => {
it('no configured should not be deleted', async () => {
const w1 = await WorkflowModel.create({
enabled: true,
type: 'collection',
config: {
mode: 1,
collection: 'posts',
},
});
const p1 = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const executions = await w1.getExecutions();
expect(executions.length).toBe(1);
});
it('status on started should not be deleted', async () => {
const w1 = await WorkflowModel.create({
enabled: true,
options: {
deleteExecutionOnStatus: [EXECUTION_STATUS.STARTED],
},
type: 'collection',
config: {
mode: 1,
collection: 'posts',
},
});
await w1.createNode({
type: 'pending',
});
const p1 = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const executions = await w1.getExecutions();
expect(executions.length).toBe(1);
expect(executions[0].status).toBe(EXECUTION_STATUS.STARTED);
});
it('configured resolved status should be deleted', async () => {
const w1 = await WorkflowModel.create({
enabled: true,
options: {
deleteExecutionOnStatus: [EXECUTION_STATUS.RESOLVED],
},
type: 'collection',
config: {
mode: 1,
collection: 'posts',
},
});
const p1 = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const executions = await w1.getExecutions();
expect(executions.length).toBe(0);
});
it('configured error status should be deleted', async () => {
const w1 = await WorkflowModel.create({
enabled: true,
options: {
deleteExecutionOnStatus: [EXECUTION_STATUS.ERROR],
},
type: 'collection',
config: {
mode: 1,
collection: 'posts',
},
});
await w1.createNode({
type: 'error',
});
const p1 = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const executions = await w1.getExecutions();
expect(executions.length).toBe(0);
});
});
}); });

View File

@ -5,7 +5,7 @@ export async function update(context: Context, next) {
const repository = utils.getRepositoryFromParams(context) as Repository; const repository = utils.getRepositoryFromParams(context) as Repository;
const { filterByTk, values } = context.action.params; const { filterByTk, values } = context.action.params;
context.action.mergeParams({ context.action.mergeParams({
whitelist: ['title', 'description', 'enabled', 'config'], whitelist: ['title', 'description', 'enabled', 'config', 'options'],
}); });
// only enable/disable // only enable/disable
if (Object.keys(values).includes('config')) { if (Object.keys(values).includes('config')) {

View File

@ -76,6 +76,11 @@ export default function () {
constraints: false, constraints: false,
onDelete: 'NO ACTION', onDelete: 'NO ACTION',
}, },
{
type: 'jsonb',
name: 'options',
defaultValue: {},
},
], ],
// NOTE: use unique index for avoiding deadlock in mysql when setCurrent // NOTE: use unique index for avoiding deadlock in mysql when setCurrent
indexes: [ indexes: [

View File

@ -0,0 +1,32 @@
import { Migration } from '@nocobase/server';
export default class extends Migration {
async up() {
const match = await this.app.version.satisfies('<0.11.0-alpha.2');
if (!match) {
return;
}
const { db } = this.context;
const WorkflowRepo = db.getRepository('flow_nodes');
await db.sequelize.transaction(async (transaction) => {
const workflows = await WorkflowRepo.find({
transaction,
});
await workflows.reduce(
(promise, workflow) =>
promise.then(() => {
workflow.set('options', {
useTransaction: workflow.get('useTransaction'),
});
workflow.changed('options', true);
return workflow.save({
silent: true,
transaction,
});
}),
Promise.resolve(),
);
});
}
}