mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-05 05:29:26 +08:00
feat: async task manager (#5979)
This commit is contained in:
parent
ed6d6f9f9a
commit
23ac4eb229
@ -0,0 +1,2 @@
|
||||
/node_modules
|
||||
/src
|
@ -0,0 +1 @@
|
||||
# @nocobase/plugin-async-export
|
2
packages/plugins/@nocobase/plugin-async-task-manager/client.d.ts
vendored
Normal file
2
packages/plugins/@nocobase/plugin-async-task-manager/client.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './dist/client';
|
||||
export { default } from './dist/client';
|
@ -0,0 +1 @@
|
||||
module.exports = require('./dist/client/index.js');
|
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@nocobase/plugin-async-task-manager",
|
||||
"displayName": "Async task manager",
|
||||
"displayName.zh-CN": "异步任务管理器",
|
||||
"description": "Manage and monitor asynchronous tasks such as data import/export. Support task progress tracking and notification.",
|
||||
"description.zh-CN": "管理和监控数据导入导出等异步任务。支持任务进度跟踪和通知。",
|
||||
"version": "1.5.0-beta.19",
|
||||
"main": "dist/server/index.js",
|
||||
"dependencies": {},
|
||||
"peerDependencies": {
|
||||
"@nocobase/client": "1.x",
|
||||
"@nocobase/server": "1.x",
|
||||
"@nocobase/test": "1.x"
|
||||
}
|
||||
}
|
2
packages/plugins/@nocobase/plugin-async-task-manager/server.d.ts
vendored
Normal file
2
packages/plugins/@nocobase/plugin-async-task-manager/server.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './dist/server';
|
||||
export { default } from './dist/server';
|
@ -0,0 +1 @@
|
||||
module.exports = require('./dist/server/index.js');
|
@ -0,0 +1,176 @@
|
||||
import { PinnedPluginListProvider, SchemaComponentOptions, useApp } from '@nocobase/client';
|
||||
import { AsyncTasks } from './components/AsyncTasks';
|
||||
import React, { useEffect, useState, createContext, useContext, useCallback } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { useT } from './locale';
|
||||
|
||||
export const AsyncTaskContext = createContext<any>(null);
|
||||
|
||||
export const useAsyncTask = () => {
|
||||
const context = useContext(AsyncTaskContext);
|
||||
if (!context) {
|
||||
throw new Error('useAsyncTask must be used within AsyncTaskManagerProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const AsyncTaskManagerProvider = (props) => {
|
||||
const app = useApp();
|
||||
const t = useT();
|
||||
const [tasks, setTasks] = useState<any[]>([]);
|
||||
const [popoverVisible, setPopoverVisible] = useState(false);
|
||||
const [hasProcessingTasks, setHasProcessingTasks] = useState(false);
|
||||
const [cancellingTasks, setCancellingTasks] = useState<Set<string>>(new Set());
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [currentError, setCurrentError] = useState<any>(null);
|
||||
const [resultModalVisible, setResultModalVisible] = useState(false);
|
||||
const [currentTask, setCurrentTask] = useState(null);
|
||||
const [wsAuthorized, setWsAuthorized] = useState(() => app.isWsAuthorized);
|
||||
|
||||
useEffect(() => {
|
||||
setHasProcessingTasks(tasks.some((task) => task.status.type !== 'success' && task.status.type !== 'failed'));
|
||||
}, [tasks]);
|
||||
|
||||
const handleTaskMessage = useCallback((event: CustomEvent) => {
|
||||
const tasks = event.detail;
|
||||
setTasks(tasks ? tasks.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) : []);
|
||||
}, []);
|
||||
|
||||
const handleTaskCreated = useCallback((event: CustomEvent) => {
|
||||
const taskData = event.detail;
|
||||
setTasks((prev) => {
|
||||
const newTasks = [taskData, ...prev];
|
||||
return newTasks.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
});
|
||||
setPopoverVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleTaskProgress = useCallback((event: CustomEvent) => {
|
||||
const { taskId, progress } = event.detail;
|
||||
setTasks((prev) => prev.map((task) => (task.taskId === taskId ? { ...task, progress } : task)));
|
||||
}, []);
|
||||
|
||||
const handleTaskStatus = useCallback((event: CustomEvent) => {
|
||||
const { taskId, status } = event.detail;
|
||||
if (status.type === 'cancelled') {
|
||||
setTasks((prev) => prev.filter((task) => task.taskId !== taskId));
|
||||
} else {
|
||||
setTasks((prev) => {
|
||||
const newTasks = prev.map((task) => {
|
||||
if (task.taskId === taskId) {
|
||||
if (status.type === 'success' && task.status.type !== 'success') {
|
||||
message.success(t('Task completed'));
|
||||
}
|
||||
if (status.type === 'failed' && task.status.type !== 'failed') {
|
||||
message.error(t('Task failed'));
|
||||
}
|
||||
return { ...task, status };
|
||||
}
|
||||
return task;
|
||||
});
|
||||
return newTasks;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleWsAuthorized = useCallback(() => {
|
||||
setWsAuthorized(true);
|
||||
}, []);
|
||||
|
||||
const handleTaskCancelled = useCallback((event: CustomEvent) => {
|
||||
const { taskId } = event.detail;
|
||||
setCancellingTasks((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(taskId);
|
||||
return newSet;
|
||||
});
|
||||
message.success(t('Task cancelled'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
app.eventBus.addEventListener('ws:message:async-tasks', handleTaskMessage);
|
||||
app.eventBus.addEventListener('ws:message:async-tasks:created', handleTaskCreated);
|
||||
app.eventBus.addEventListener('ws:message:async-tasks:progress', handleTaskProgress);
|
||||
app.eventBus.addEventListener('ws:message:async-tasks:status', handleTaskStatus);
|
||||
app.eventBus.addEventListener('ws:message:authorized', handleWsAuthorized);
|
||||
app.eventBus.addEventListener('ws:message:async-tasks:cancelled', handleTaskCancelled);
|
||||
|
||||
if (wsAuthorized) {
|
||||
app.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'request:async-tasks:list',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
app.eventBus.removeEventListener('ws:message:async-tasks', handleTaskMessage);
|
||||
app.eventBus.removeEventListener('ws:message:async-tasks:created', handleTaskCreated);
|
||||
app.eventBus.removeEventListener('ws:message:async-tasks:progress', handleTaskProgress);
|
||||
app.eventBus.removeEventListener('ws:message:async-tasks:status', handleTaskStatus);
|
||||
app.eventBus.removeEventListener('ws:message:authorized', handleWsAuthorized);
|
||||
app.eventBus.removeEventListener('ws:message:async-tasks:cancelled', handleTaskCancelled);
|
||||
};
|
||||
}, [
|
||||
app,
|
||||
handleTaskMessage,
|
||||
handleTaskCreated,
|
||||
handleTaskProgress,
|
||||
handleTaskStatus,
|
||||
handleWsAuthorized,
|
||||
handleTaskCancelled,
|
||||
wsAuthorized,
|
||||
]);
|
||||
|
||||
const handleCancelTask = async (taskId: string) => {
|
||||
setCancellingTasks((prev) => new Set(prev).add(taskId));
|
||||
try {
|
||||
app.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'request:async-tasks:cancel',
|
||||
payload: { taskId },
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel task:', error);
|
||||
setCancellingTasks((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(taskId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const contextValue = {
|
||||
tasks,
|
||||
popoverVisible,
|
||||
setPopoverVisible,
|
||||
hasProcessingTasks,
|
||||
cancellingTasks,
|
||||
modalVisible,
|
||||
setModalVisible,
|
||||
currentError,
|
||||
setCurrentError,
|
||||
resultModalVisible,
|
||||
setResultModalVisible,
|
||||
currentTask,
|
||||
setCurrentTask,
|
||||
handleCancelTask,
|
||||
};
|
||||
|
||||
return (
|
||||
<AsyncTaskContext.Provider value={contextValue}>
|
||||
<PinnedPluginListProvider
|
||||
items={
|
||||
tasks.length > 0
|
||||
? {
|
||||
asyncTasks: { order: 300, component: 'AsyncTasks', pin: true, snippet: '*' },
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<SchemaComponentOptions components={{ AsyncTasks }}>{props.children}</SchemaComponentOptions>
|
||||
</PinnedPluginListProvider>
|
||||
</AsyncTaskContext.Provider>
|
||||
);
|
||||
};
|
@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
export class TaskResultRendererManager {
|
||||
private renderers = new Map<string, React.ComponentType<any>>();
|
||||
|
||||
register(type: string, renderer: React.ComponentType<any>) {
|
||||
this.renderers.set(type, renderer);
|
||||
}
|
||||
|
||||
get(type: string) {
|
||||
return this.renderers.get(type);
|
||||
}
|
||||
}
|
249
packages/plugins/@nocobase/plugin-async-task-manager/src/client/client.d.ts
vendored
Normal file
249
packages/plugins/@nocobase/plugin-async-task-manager/src/client/client.d.ts
vendored
Normal file
@ -0,0 +1,249 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
// CSS modules
|
||||
type CSSModuleClasses = { readonly [key: string]: string };
|
||||
|
||||
declare module '*.module.css' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.scss' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.sass' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.less' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.styl' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.stylus' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.pcss' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.sss' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
|
||||
// CSS
|
||||
declare module '*.css' { }
|
||||
declare module '*.scss' { }
|
||||
declare module '*.sass' { }
|
||||
declare module '*.less' { }
|
||||
declare module '*.styl' { }
|
||||
declare module '*.stylus' { }
|
||||
declare module '*.pcss' { }
|
||||
declare module '*.sss' { }
|
||||
|
||||
// Built-in asset types
|
||||
// see `src/node/constants.ts`
|
||||
|
||||
// images
|
||||
declare module '*.apng' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.png' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.jpg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.jpeg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.jfif' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.pjpeg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.pjp' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.gif' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.svg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.ico' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.webp' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.avif' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
// media
|
||||
declare module '*.mp4' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.webm' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.ogg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.mp3' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.wav' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.flac' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.aac' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.opus' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.mov' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.m4a' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.vtt' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
// fonts
|
||||
declare module '*.woff' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.woff2' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.eot' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.ttf' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.otf' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
// other
|
||||
declare module '*.webmanifest' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.pdf' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.txt' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
// wasm?init
|
||||
declare module '*.wasm?init' {
|
||||
const initWasm: (options?: WebAssembly.Imports) => Promise<WebAssembly.Instance>;
|
||||
export default initWasm;
|
||||
}
|
||||
|
||||
// web worker
|
||||
declare module '*?worker' {
|
||||
const workerConstructor: {
|
||||
new(options?: { name?: string }): Worker;
|
||||
};
|
||||
export default workerConstructor;
|
||||
}
|
||||
|
||||
declare module '*?worker&inline' {
|
||||
const workerConstructor: {
|
||||
new(options?: { name?: string }): Worker;
|
||||
};
|
||||
export default workerConstructor;
|
||||
}
|
||||
|
||||
declare module '*?worker&url' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*?sharedworker' {
|
||||
const sharedWorkerConstructor: {
|
||||
new(options?: { name?: string }): SharedWorker;
|
||||
};
|
||||
export default sharedWorkerConstructor;
|
||||
}
|
||||
|
||||
declare module '*?sharedworker&inline' {
|
||||
const sharedWorkerConstructor: {
|
||||
new(options?: { name?: string }): SharedWorker;
|
||||
};
|
||||
export default sharedWorkerConstructor;
|
||||
}
|
||||
|
||||
declare module '*?sharedworker&url' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*?raw' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*?url' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*?inline' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
@ -0,0 +1,400 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Button, Popover, Table, Tag, Progress, Space, Tooltip, Popconfirm, Modal, Empty } from 'antd';
|
||||
import { Icon, useApp, usePlugin } from '@nocobase/client';
|
||||
import { ExclamationCircleOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { useT } from '../locale';
|
||||
import { useAsyncTask } from '../AsyncTaskManagerProvider';
|
||||
|
||||
// Configure dayjs
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const renderTaskResult = (status, t) => {
|
||||
if (status.type !== 'success' || !status.payload?.message?.messageId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { messageId, messageValues } = status.payload.message;
|
||||
|
||||
return (
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
<Tag color="success">{t(messageId, messageValues)}</Tag>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AsyncTasks = () => {
|
||||
const {
|
||||
tasks,
|
||||
popoverVisible,
|
||||
setPopoverVisible,
|
||||
hasProcessingTasks,
|
||||
cancellingTasks,
|
||||
modalVisible,
|
||||
setModalVisible,
|
||||
currentError,
|
||||
setCurrentError,
|
||||
resultModalVisible,
|
||||
setResultModalVisible,
|
||||
currentTask,
|
||||
setCurrentTask,
|
||||
handleCancelTask,
|
||||
} = useAsyncTask();
|
||||
|
||||
const plugin = usePlugin<any>('async-task-manager');
|
||||
const app = useApp();
|
||||
const t = useT();
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (popoverVisible) {
|
||||
const popoverElements = document.querySelectorAll('.ant-popover');
|
||||
const buttonElement = document.querySelector('.sync-task-button');
|
||||
let clickedInside = false;
|
||||
|
||||
popoverElements.forEach((element) => {
|
||||
if (element.contains(event.target as Node)) {
|
||||
clickedInside = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (buttonElement?.contains(event.target as Node)) {
|
||||
clickedInside = true;
|
||||
}
|
||||
|
||||
if (!clickedInside) {
|
||||
setPopoverVisible(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
}, [popoverVisible, setPopoverVisible]);
|
||||
|
||||
const showTaskResult = (task) => {
|
||||
setCurrentTask(task);
|
||||
setResultModalVisible(true);
|
||||
setPopoverVisible(false);
|
||||
};
|
||||
|
||||
const renderTaskResultModal = () => {
|
||||
if (!currentTask) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { payload } = currentTask.status;
|
||||
const renderer = plugin.taskResultRendererManager.get(currentTask.title.actionType);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('Task result')}
|
||||
open={resultModalVisible}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setResultModalVisible(false)}>
|
||||
{t('Close')}
|
||||
</Button>,
|
||||
]}
|
||||
onCancel={() => setResultModalVisible(false)}
|
||||
>
|
||||
{renderer ? (
|
||||
React.createElement(renderer, { payload, task: currentTask })
|
||||
) : (
|
||||
<div>{t(`No renderer available for this task type, payload: ${payload}`)}</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('Created at'),
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
render: (createdAt: string) => (
|
||||
<Tooltip title={dayjs(createdAt).format('YYYY-MM-DD HH:mm:ss')}>{dayjs(createdAt).fromNow()}</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('Task'),
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
render: (_, record: any) => {
|
||||
const title = record.title;
|
||||
if (!title) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const actionTypeMap = {
|
||||
export: t('Export'),
|
||||
import: t('Import'),
|
||||
'export-attachments': t('Export attachments'),
|
||||
};
|
||||
|
||||
const actionText = actionTypeMap[title.actionType] || title.actionType;
|
||||
const taskTypeMap = {
|
||||
'export-attachments': t('Export {collection} attachments'),
|
||||
export: t('Export {collection} data'),
|
||||
import: t('Import {collection} data'),
|
||||
};
|
||||
|
||||
const taskTemplate = taskTypeMap[title.actionType] || `${actionText} ${title.collection} ${t('Data')}`;
|
||||
return taskTemplate.replace('{collection}', title.collection);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('Status'),
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 160,
|
||||
render: (status: any, record: any) => {
|
||||
const statusMap = {
|
||||
pending: {
|
||||
color: 'default',
|
||||
text: t('Waiting'),
|
||||
icon: 'ClockCircleOutlined',
|
||||
},
|
||||
running: {
|
||||
color: 'processing',
|
||||
text: t('Processing'),
|
||||
icon: 'LoadingOutlined',
|
||||
},
|
||||
success: {
|
||||
color: 'success',
|
||||
text: t('Completed'),
|
||||
icon: 'CheckCircleOutlined',
|
||||
},
|
||||
failed: {
|
||||
color: 'error',
|
||||
text: t('Failed'),
|
||||
icon: 'CloseCircleOutlined',
|
||||
},
|
||||
cancelled: {
|
||||
color: 'warning',
|
||||
text: t('Cancelled'),
|
||||
icon: 'StopOutlined',
|
||||
},
|
||||
};
|
||||
|
||||
const { color, text } = statusMap[status.type] || {};
|
||||
|
||||
const renderProgress = () => {
|
||||
const commonStyle = {
|
||||
width: 100,
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
switch (status.indicator) {
|
||||
case 'spinner':
|
||||
return (
|
||||
<Progress
|
||||
type="line"
|
||||
size="small"
|
||||
strokeWidth={4}
|
||||
percent={100}
|
||||
status="active"
|
||||
showInfo={false}
|
||||
style={commonStyle}
|
||||
/>
|
||||
);
|
||||
case 'progress':
|
||||
return (
|
||||
<Progress
|
||||
type="line"
|
||||
size="small"
|
||||
strokeWidth={4}
|
||||
percent={Number(((record.progress?.current / record.progress?.total) * 100).toFixed(2))}
|
||||
status="active"
|
||||
style={commonStyle}
|
||||
format={(percent) => `${percent.toFixed(1)}%`}
|
||||
/>
|
||||
);
|
||||
case 'success':
|
||||
return (
|
||||
<Progress
|
||||
type="line"
|
||||
size="small"
|
||||
strokeWidth={4}
|
||||
percent={100}
|
||||
status="success"
|
||||
style={commonStyle}
|
||||
format={() => ''}
|
||||
/>
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<Progress
|
||||
type="line"
|
||||
size="small"
|
||||
strokeWidth={4}
|
||||
percent={100}
|
||||
status="exception"
|
||||
style={commonStyle}
|
||||
format={() => ''}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ flex: 1 }}>{renderProgress()}</div>
|
||||
<Tag
|
||||
color={color}
|
||||
icon={statusMap[status.type]?.icon ? <Icon type={statusMap[status.type].icon} /> : null}
|
||||
style={{ margin: 0, padding: '0 4px', height: 22, width: 22 }}
|
||||
/>
|
||||
{renderTaskResult(status, t)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('Actions'),
|
||||
key: 'actions',
|
||||
width: 180,
|
||||
render: (_, record: any) => {
|
||||
const actions = [];
|
||||
const isTaskCancelling = cancellingTasks.has(record.taskId);
|
||||
|
||||
if (record.status.type === 'running' || record.status.type === 'pending') {
|
||||
actions.push(
|
||||
<Popconfirm
|
||||
key="cancel"
|
||||
title={t('Confirm cancel')}
|
||||
description={t('Confirm cancel description')}
|
||||
onConfirm={() => handleCancelTask(record.taskId)}
|
||||
okText={t('Confirm')}
|
||||
cancelText={t('Cancel')}
|
||||
disabled={isTaskCancelling}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<Icon type={isTaskCancelling ? 'LoadingOutlined' : 'StopOutlined'} />}
|
||||
disabled={isTaskCancelling}
|
||||
>
|
||||
{isTaskCancelling ? t('Cancelling') : t('Cancel')}
|
||||
</Button>
|
||||
</Popconfirm>,
|
||||
);
|
||||
}
|
||||
|
||||
if (record.status.type === 'success') {
|
||||
if (record.status.resultType === 'file') {
|
||||
actions.push(
|
||||
<Button
|
||||
key="download"
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<Icon type="DownloadOutlined" />}
|
||||
onClick={() => {
|
||||
const token = app.apiClient.auth.token;
|
||||
const url = app.getApiUrl(
|
||||
`asyncTasks:fetchFile/${record.taskId}?token=${token}&__appName=${app.name}`,
|
||||
);
|
||||
window.open(url);
|
||||
}}
|
||||
>
|
||||
{t('Download')}
|
||||
</Button>,
|
||||
);
|
||||
} else if (record.status.payload) {
|
||||
actions.push(
|
||||
<Button
|
||||
key="view"
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<Icon type="EyeOutlined" />}
|
||||
onClick={() => showTaskResult(record)}
|
||||
>
|
||||
{t('View result')}
|
||||
</Button>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (record.status.type === 'failed') {
|
||||
actions.push(
|
||||
<Button
|
||||
key="error"
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<Icon type="ExclamationCircleOutlined" />}
|
||||
onClick={() => {
|
||||
setCurrentError(record.status.errors);
|
||||
setModalVisible(true);
|
||||
setPopoverVisible(false);
|
||||
}}
|
||||
>
|
||||
{t('Error details')}
|
||||
</Button>,
|
||||
);
|
||||
}
|
||||
|
||||
return <Space size="middle">{actions}</Space>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const content = (
|
||||
<div style={{ width: tasks.length > 0 ? 800 : 200 }}>
|
||||
{tasks.length > 0 ? (
|
||||
<Table columns={columns} dataSource={tasks} size="small" pagination={false} rowKey="taskId" />
|
||||
) : (
|
||||
<div style={{ padding: '24px 0', display: 'flex', justifyContent: 'center' }}>
|
||||
<Empty description={t('No tasks')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
content={content}
|
||||
trigger="hover"
|
||||
placement="bottom"
|
||||
open={popoverVisible}
|
||||
onOpenChange={setPopoverVisible}
|
||||
>
|
||||
<Button
|
||||
className="sync-task-button"
|
||||
icon={<Icon type={'SyncOutlined'} spin={hasProcessingTasks} />}
|
||||
onClick={() => setPopoverVisible(!popoverVisible)}
|
||||
/>
|
||||
</Popover>
|
||||
{renderTaskResultModal()}
|
||||
|
||||
<Modal
|
||||
title={t('Error Details')}
|
||||
open={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
footer={[
|
||||
<Button key="ok" type="primary" onClick={() => setModalVisible(false)}>
|
||||
{t('OK')}
|
||||
</Button>,
|
||||
]}
|
||||
width={400}
|
||||
>
|
||||
{currentError?.map((error, index) => (
|
||||
<div key={index} style={{ marginBottom: 16 }}>
|
||||
<div style={{ color: '#ff4d4f', marginBottom: 8 }}>{error.message}</div>
|
||||
{error.code && (
|
||||
<div style={{ color: '#999', fontSize: 12 }}>
|
||||
{t('Error code')}: {error.code}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,13 @@
|
||||
import { Plugin } from '@nocobase/client';
|
||||
import { AsyncTaskManagerProvider } from './AsyncTaskManagerProvider';
|
||||
import { TaskResultRendererManager } from './TaskResultRendererManager';
|
||||
|
||||
export class PluginAsyncExportClient extends Plugin {
|
||||
taskResultRendererManager: TaskResultRendererManager = new TaskResultRendererManager();
|
||||
|
||||
async load() {
|
||||
this.app.use(AsyncTaskManagerProvider);
|
||||
}
|
||||
}
|
||||
|
||||
export default PluginAsyncExportClient;
|
@ -0,0 +1,12 @@
|
||||
// @ts-ignore
|
||||
import pkg from '../../package.json';
|
||||
import { useApp } from '@nocobase/client';
|
||||
|
||||
export function useT() {
|
||||
const app = useApp();
|
||||
return (str: string) => app.i18n.t(str, { ns: [pkg.name, 'client'] });
|
||||
}
|
||||
|
||||
export function tStr(key: string) {
|
||||
return `{{t(${JSON.stringify(key)}, { ns: ['${pkg.name}', 'client'], nsMode: 'fallback' })}}`;
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
export * from './server';
|
||||
export { default } from './server';
|
@ -0,0 +1,5 @@
|
||||
{
|
||||
"Export {collection} attachments": "{collection} attachments export",
|
||||
"Export {collection} data": "{collection} data export",
|
||||
"Import {collection} data": "{collection} data import"
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
{
|
||||
"Export": "导出",
|
||||
"Import": "导入",
|
||||
"Data": "数据",
|
||||
"Task": "任务",
|
||||
"Status": "状态",
|
||||
"Actions": "操作",
|
||||
"Created at": "创建时间",
|
||||
"Type": "类型",
|
||||
"Waiting": "等待中",
|
||||
"Processing": "进行中",
|
||||
"Completed": "已完成",
|
||||
"Failed": "执行失败",
|
||||
"Cancelled": "已取消",
|
||||
"Cancel": "取消",
|
||||
"Cancelling": "取消中",
|
||||
"Download": "下载",
|
||||
"Error details": "错误详情",
|
||||
"Confirm cancel": "确认取消",
|
||||
"Confirm cancel description": "确定要取消这个任务吗?",
|
||||
"Confirm": "确定",
|
||||
"Task cancelled": "任务已取消",
|
||||
"Task completed": "任务已完成",
|
||||
"Task failed": "任务执行失败",
|
||||
"Error Details": "错误详情",
|
||||
"Close": "关闭",
|
||||
"Error code": "错误代码",
|
||||
"Unknown error": "未知错误",
|
||||
"OK": "确定",
|
||||
"Import result": "导入结果",
|
||||
"Import completed": "导入完成:{{success}} 条记录已导入,{{updated}} 条记录已更新,{{skipped}} 条记录已跳过,共 {{total}} 条记录",
|
||||
"Import summary": "已导入 {{success}}/{{total}} 条记录",
|
||||
"Import details": "成功导入 {{success}} 条,更新 {{updated}} 条,跳过 {{skipped}} 条,共 {{total}} 条",
|
||||
"Imported": "已导入 {{count}}/{{total}}",
|
||||
"Successfully imported": "成功导入",
|
||||
"Updated records": "已更新记录",
|
||||
"Skipped records": "已跳过记录",
|
||||
"Total records": "总记录数",
|
||||
"View result": "查看结果",
|
||||
"ImportResult": "已导入 {{success}} 条,更新 {{updated}} 条,跳过 {{skipped}} 条,共 {{total}} 条",
|
||||
"Task result": "任务结果",
|
||||
"Export {collection} attachments": "导出{collection}附件",
|
||||
"Export {collection} data": "导出{collection}记录",
|
||||
"Import {collection} data": "导入{collection}数据"
|
||||
}
|
@ -0,0 +1,462 @@
|
||||
import { AsyncTasksManager, TaskStatus, CancelError } from '../interfaces/async-task-manager';
|
||||
import { createMockServer } from '@nocobase/test';
|
||||
import { TaskType } from '../task-type';
|
||||
|
||||
describe('task manager', () => {
|
||||
let taskManager: AsyncTasksManager;
|
||||
|
||||
let app;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await createMockServer({
|
||||
plugins: ['nocobase', 'async-task-manager'],
|
||||
});
|
||||
|
||||
taskManager = app.container.get('AsyncTaskManager');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it('should register task type', async () => {
|
||||
class TestTaskType extends TaskType {
|
||||
static type = 'test';
|
||||
|
||||
async execute() {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
this.reportProgress({
|
||||
total: 10,
|
||||
current: i,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
a: 'b',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
taskManager.registerTaskType(TestTaskType);
|
||||
|
||||
const task = await taskManager.createTask({
|
||||
type: 'test',
|
||||
params: {},
|
||||
});
|
||||
|
||||
expect(task).toBeTruthy();
|
||||
|
||||
expect(task.status.type).toBe('pending');
|
||||
// should get tasks status through task id
|
||||
const getResp = await app.agent().resource('asyncTasks').get({
|
||||
filterByTk: task.taskId,
|
||||
});
|
||||
|
||||
expect(getResp.status).toBe(200);
|
||||
|
||||
const testFn = vi.fn();
|
||||
|
||||
task.on('progress', (progress) => {
|
||||
testFn();
|
||||
});
|
||||
|
||||
await task.run();
|
||||
|
||||
expect(testFn).toHaveBeenCalledTimes(10);
|
||||
|
||||
const getResp2 = await app.agent().resource('asyncTasks').get({
|
||||
filterByTk: task.taskId,
|
||||
});
|
||||
|
||||
expect(getResp2.status).toBe(200);
|
||||
|
||||
expect(getResp2.body.data.type).toBe('success');
|
||||
expect(getResp2.body.data.payload).toEqual({
|
||||
a: 'b',
|
||||
});
|
||||
});
|
||||
|
||||
it('should get tasks by tag', async () => {
|
||||
class TestTaskType extends TaskType {
|
||||
static type = 'test';
|
||||
|
||||
async execute() {
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
taskManager.registerTaskType(TestTaskType);
|
||||
|
||||
const task1 = await taskManager.createTask({
|
||||
type: 'test',
|
||||
params: {},
|
||||
tags: {
|
||||
category: 'import',
|
||||
source: 'excel',
|
||||
},
|
||||
});
|
||||
|
||||
const task2 = await taskManager.createTask({
|
||||
type: 'test',
|
||||
params: {},
|
||||
tags: {
|
||||
category: 'import',
|
||||
source: 'csv',
|
||||
},
|
||||
});
|
||||
|
||||
const task3 = await taskManager.createTask({
|
||||
type: 'test',
|
||||
params: {},
|
||||
tags: {
|
||||
category: 'export',
|
||||
source: 'excel',
|
||||
},
|
||||
});
|
||||
|
||||
const importTasks = await taskManager.getTasksByTag('category', 'import');
|
||||
expect(importTasks.length).toBe(2);
|
||||
|
||||
const excelTasks = await taskManager.getTasksByTag('source', 'excel');
|
||||
expect(excelTasks.length).toBe(2);
|
||||
|
||||
const csvTasks = await taskManager.getTasksByTag('source', 'csv');
|
||||
expect(csvTasks.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should emit events when task status changes', async () => {
|
||||
class TestTaskType extends TaskType {
|
||||
static type = 'test';
|
||||
|
||||
async execute() {
|
||||
this.reportProgress({ total: 10, current: 5 });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
taskManager.registerTaskType(TestTaskType);
|
||||
|
||||
const taskCreatedFn = vi.fn();
|
||||
const taskProgressFn = vi.fn();
|
||||
const taskStatusChangeFn = vi.fn();
|
||||
|
||||
taskManager.on('taskCreated', taskCreatedFn);
|
||||
taskManager.on('taskProgress', taskProgressFn);
|
||||
taskManager.on('taskStatusChange', (event) => {
|
||||
console.log('taskStatusChange', event);
|
||||
taskStatusChangeFn(event);
|
||||
});
|
||||
|
||||
const task = await taskManager.createTask({
|
||||
type: 'test',
|
||||
params: {},
|
||||
});
|
||||
|
||||
// 测试任务创建事件
|
||||
expect(taskCreatedFn).toHaveBeenCalledTimes(1);
|
||||
expect(taskCreatedFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
task: expect.any(TestTaskType),
|
||||
}),
|
||||
);
|
||||
|
||||
await task.run();
|
||||
|
||||
// 测试进度事件
|
||||
expect(taskProgressFn).toHaveBeenCalledTimes(1);
|
||||
expect(taskProgressFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
task,
|
||||
progress: { total: 10, current: 5 },
|
||||
}),
|
||||
);
|
||||
|
||||
// 测试状态变更事件
|
||||
expect(taskStatusChangeFn).toHaveBeenCalledTimes(2); // 初始 running 状态和最终 success 状态
|
||||
expect(taskStatusChangeFn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
task,
|
||||
status: {
|
||||
type: 'running',
|
||||
indicator: 'progress',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(taskStatusChangeFn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
task,
|
||||
status: {
|
||||
type: 'success',
|
||||
indicator: 'success',
|
||||
payload: { success: true },
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit events when task fails', async () => {
|
||||
class FailingTaskType extends TaskType {
|
||||
static type = 'failing';
|
||||
|
||||
async execute() {
|
||||
throw new Error('Task failed');
|
||||
}
|
||||
}
|
||||
|
||||
taskManager.registerTaskType(FailingTaskType);
|
||||
|
||||
const taskStatusChangeFn = vi.fn();
|
||||
taskManager.on('taskStatusChange', taskStatusChangeFn);
|
||||
|
||||
const task = taskManager.createTask({
|
||||
type: 'failing',
|
||||
params: {},
|
||||
});
|
||||
|
||||
// 使用 try/catch 来处理预期的错误
|
||||
try {
|
||||
await task.run();
|
||||
} catch (error) {
|
||||
console.log('error', error);
|
||||
// 错误已经被 TaskType 的 run 方法处理了,这里不需要做任何事
|
||||
}
|
||||
|
||||
expect(taskStatusChangeFn).toHaveBeenCalledTimes(2); // 一次是 running 状态,一次是 failed 状态
|
||||
expect(taskStatusChangeFn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
task,
|
||||
status: {
|
||||
type: 'running',
|
||||
indicator: 'progress',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(taskStatusChangeFn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
task,
|
||||
status: {
|
||||
type: 'failed',
|
||||
indicator: 'error',
|
||||
errors: [{ message: 'Task failed' }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle task progress correctly', async () => {
|
||||
class TestTaskType extends TaskType {
|
||||
static type = 'test';
|
||||
|
||||
async execute() {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
this.reportProgress({
|
||||
total: 10,
|
||||
current: i + 1,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
completed: true,
|
||||
processedItems: 10,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
taskManager.registerTaskType(TestTaskType);
|
||||
|
||||
const progressUpdates = [];
|
||||
const statusChanges = [];
|
||||
|
||||
const task = await taskManager.createTask({
|
||||
type: 'test',
|
||||
params: {},
|
||||
});
|
||||
|
||||
// 监听进度更新
|
||||
task.on('progress', (progress) => {
|
||||
progressUpdates.push(progress);
|
||||
});
|
||||
|
||||
// 监听状态变化
|
||||
task.on('statusChange', (status) => {
|
||||
statusChanges.push(status);
|
||||
});
|
||||
|
||||
// 运行任务
|
||||
await task.run();
|
||||
|
||||
// 验证进度更新
|
||||
expect(progressUpdates.length).toBe(10);
|
||||
expect(progressUpdates[0]).toEqual({
|
||||
total: 10,
|
||||
current: 1,
|
||||
});
|
||||
expect(progressUpdates[9]).toEqual({
|
||||
total: 10,
|
||||
current: 10,
|
||||
});
|
||||
|
||||
// 验证状态变化
|
||||
expect(statusChanges.length).toBe(2); // pending -> running -> success
|
||||
expect(statusChanges[0].type).toBe('running');
|
||||
expect(statusChanges[1].type).toBe('success');
|
||||
expect(statusChanges[1].payload).toEqual({
|
||||
completed: true,
|
||||
processedItems: 10,
|
||||
});
|
||||
|
||||
// 验证最终任务状态和进度
|
||||
const finalTask = await app.agent().resource('asyncTasks').get({
|
||||
filterByTk: task.taskId,
|
||||
});
|
||||
|
||||
expect(finalTask.body.data.type).toBe('success');
|
||||
});
|
||||
|
||||
it('should cancel task correctly', async () => {
|
||||
let executionCompleted = false;
|
||||
|
||||
class LongRunningTaskType extends TaskType {
|
||||
static type = 'long-running';
|
||||
|
||||
async execute() {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (this.isCancelled) {
|
||||
throw new CancelError();
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
this.reportProgress({
|
||||
total: 10,
|
||||
current: i + 1,
|
||||
});
|
||||
}
|
||||
executionCompleted = true;
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
taskManager.registerTaskType(LongRunningTaskType);
|
||||
|
||||
const statusChanges: TaskStatus[] = [];
|
||||
const task = await taskManager.createTask({
|
||||
type: 'long-running',
|
||||
params: {},
|
||||
});
|
||||
|
||||
task.on('statusChange', (status) => {
|
||||
statusChanges.push(status);
|
||||
});
|
||||
|
||||
// 启动任务
|
||||
const runPromise = task.run();
|
||||
|
||||
// 等待一小段时间让任务开始执行
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
// 取消任务
|
||||
const cancelled = await taskManager.cancelTask(task.taskId);
|
||||
expect(cancelled).toBe(true);
|
||||
|
||||
// 等待任务完成执行
|
||||
await runPromise;
|
||||
// 等待状态变更事件被处理
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// 验证状态变化
|
||||
expect(statusChanges.length).toBe(2); // running -> cancelled
|
||||
expect(statusChanges[0].type).toBe('running');
|
||||
expect(statusChanges[1].type).toBe('cancelled');
|
||||
|
||||
// 验证任务状态
|
||||
expect(task.status.type).toBe('cancelled');
|
||||
|
||||
// 验证任务是否正确中断
|
||||
expect(executionCompleted).toBe(false);
|
||||
|
||||
// 验证无法取消不存在的任务
|
||||
expect(await taskManager.cancelTask('non-existent-id')).toBe(false);
|
||||
});
|
||||
|
||||
describe('task cancellation', () => {
|
||||
it('should remove task from memory immediately after cancellation', async () => {
|
||||
class CancellableTaskType extends TaskType {
|
||||
static type = 'cancellable';
|
||||
|
||||
async execute() {
|
||||
while (!this.isCancelled) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new CancelError();
|
||||
}
|
||||
}
|
||||
|
||||
taskManager.registerTaskType(CancellableTaskType);
|
||||
|
||||
const task = await taskManager.createTask({
|
||||
type: 'cancellable',
|
||||
params: {},
|
||||
});
|
||||
|
||||
// 启动任务
|
||||
const runPromise = task.run();
|
||||
|
||||
// 等待任务开始执行
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
// 取消任务
|
||||
const cancelled = await taskManager.cancelTask(task.taskId);
|
||||
expect(cancelled).toBe(true);
|
||||
|
||||
// 等待任务结束和状态变更事件被处理
|
||||
await runPromise;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// 验证任务已从内存中移除
|
||||
const tasks = Array.from(taskManager['tasks'].values()) as TaskType[];
|
||||
expect(tasks.length).toBe(0);
|
||||
|
||||
// 验证无法获取已删除任务的状态
|
||||
await expect(taskManager.getTaskStatus(task.taskId)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle multiple cancellation attempts', async () => {
|
||||
class MultiCancelTaskType extends TaskType {
|
||||
static type = 'multi-cancel';
|
||||
|
||||
async execute() {
|
||||
while (!this.isCancelled) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new CancelError();
|
||||
}
|
||||
}
|
||||
|
||||
taskManager.registerTaskType(MultiCancelTaskType);
|
||||
|
||||
const task = await taskManager.createTask({
|
||||
type: 'multi-cancel',
|
||||
params: {},
|
||||
});
|
||||
|
||||
// 启动任务
|
||||
const runPromise = task.run();
|
||||
|
||||
// 第一次取消
|
||||
const firstCancellation = await taskManager.cancelTask(task.taskId);
|
||||
expect(firstCancellation).toBe(true);
|
||||
|
||||
// 等待任务结束和状态变更事件被处理
|
||||
await runPromise;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// 第二次取消应该返回 false,因为任务已经从内存中移除
|
||||
const secondCancellation = await taskManager.cancelTask(task.taskId);
|
||||
expect(secondCancellation).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,134 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { AsyncTasksManager, CreateTaskOptions, TaskId, TaskStatus } from './interfaces/async-task-manager';
|
||||
import { Logger } from '@nocobase/logger';
|
||||
import { ITask, TaskConstructor } from './interfaces/task';
|
||||
import { Application } from '@nocobase/server';
|
||||
|
||||
export class BaseTaskManager extends EventEmitter implements AsyncTasksManager {
|
||||
private taskTypes: Map<string, TaskConstructor> = new Map();
|
||||
|
||||
private tasks: Map<TaskId, ITask> = new Map();
|
||||
|
||||
// Clean up completed tasks after 30 minutes by default
|
||||
private readonly cleanupDelay = 30 * 60 * 1000;
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
private app: Application;
|
||||
|
||||
setLogger(logger: Logger): void {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
setApp(app: Application): void {
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
private scheduleCleanup(taskId: TaskId) {
|
||||
setTimeout(() => {
|
||||
this.tasks.delete(taskId);
|
||||
this.logger.debug(`Task ${taskId} cleaned up after ${this.cleanupDelay}ms`);
|
||||
}, this.cleanupDelay);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
async cancelTask(taskId: TaskId): Promise<boolean> {
|
||||
const task = this.tasks.get(taskId);
|
||||
|
||||
if (!task) {
|
||||
this.logger.warn(`Attempted to cancel non-existent task ${taskId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.logger.info(`Cancelling task ${taskId}, type: ${task.constructor.name}, tags: ${JSON.stringify(task.tags)}`);
|
||||
return task.cancel();
|
||||
}
|
||||
|
||||
createTask<T>(options: CreateTaskOptions): ITask {
|
||||
const taskType = this.taskTypes.get(options.type);
|
||||
|
||||
if (!taskType) {
|
||||
this.logger.error(`Task type not found: ${options.type}, params: ${JSON.stringify(options.params)}`);
|
||||
throw new Error(`Task type ${options.type} not found`);
|
||||
}
|
||||
|
||||
this.logger.info(
|
||||
`Creating task of type: ${options.type}, params: ${JSON.stringify(options.params)}, tags: ${JSON.stringify(
|
||||
options.tags,
|
||||
)}`,
|
||||
);
|
||||
const task = new (taskType as unknown as new (
|
||||
options: CreateTaskOptions['params'],
|
||||
tags?: Record<string, string>,
|
||||
) => ITask)(options.params, options.tags);
|
||||
|
||||
task.title = options.title;
|
||||
task.setLogger(this.logger);
|
||||
task.setApp(this.app);
|
||||
task.setContext(options.context);
|
||||
|
||||
this.tasks.set(task.taskId, task);
|
||||
|
||||
this.logger.info(
|
||||
`Created new task ${task.taskId} of type ${options.type}, params: ${JSON.stringify(
|
||||
options.params,
|
||||
)}, tags: ${JSON.stringify(options.tags)}, title: ${task.title}`,
|
||||
);
|
||||
this.emit('taskCreated', { task });
|
||||
|
||||
task.on('progress', (progress) => {
|
||||
this.logger.debug(`Task ${task.taskId} progress: ${progress}`);
|
||||
this.emit('taskProgress', { task, progress });
|
||||
});
|
||||
|
||||
task.on('statusChange', (status) => {
|
||||
if (['success', 'failed'].includes(status.type)) {
|
||||
this.scheduleCleanup(task.taskId);
|
||||
} else if (status.type === 'cancelled') {
|
||||
// Remove task immediately when cancelled
|
||||
this.tasks.delete(task.taskId);
|
||||
}
|
||||
this.emit('taskStatusChange', { task, status });
|
||||
});
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
getTask(taskId: TaskId): ITask | undefined {
|
||||
const task = this.tasks.get(taskId);
|
||||
if (!task) {
|
||||
this.logger.debug(`Task not found: ${taskId}`);
|
||||
return undefined;
|
||||
}
|
||||
this.logger.debug(`Retrieved task ${taskId}, type: ${task.constructor.name}, status: ${task.status.type}`);
|
||||
return task;
|
||||
}
|
||||
|
||||
async getTaskStatus(taskId: TaskId): Promise<TaskStatus> {
|
||||
const task = this.tasks.get(taskId);
|
||||
if (!task) {
|
||||
this.logger.warn(`Attempted to get status of non-existent task ${taskId}`);
|
||||
throw new Error(`Task ${taskId} not found`);
|
||||
}
|
||||
|
||||
this.logger.debug(`Getting status for task ${taskId}, current status: ${task.status.type}`);
|
||||
return task.status;
|
||||
}
|
||||
|
||||
registerTaskType(taskType: TaskConstructor): void {
|
||||
this.logger.info(`Registering task type: ${taskType.type}`);
|
||||
this.taskTypes.set(taskType.type, taskType);
|
||||
}
|
||||
|
||||
async getTasksByTag(tagKey: string, tagValue: string): Promise<ITask[]> {
|
||||
this.logger.debug(`Getting tasks by tag - key: ${tagKey}, value: ${tagValue}`);
|
||||
const tasks = Array.from(this.tasks.values()).filter((task) => {
|
||||
return task.tags[tagKey] == tagValue;
|
||||
});
|
||||
this.logger.debug(`Found ${tasks.length} tasks with tag ${tagKey}=${tagValue}`);
|
||||
return tasks;
|
||||
}
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
import { CancelError } from './interfaces/async-task-manager';
|
||||
import process from 'node:process';
|
||||
import { Worker } from 'worker_threads';
|
||||
import path from 'path';
|
||||
import { TaskType } from './task-type';
|
||||
|
||||
export class CommandTaskType extends TaskType {
|
||||
static type = 'command';
|
||||
|
||||
workerThread: Worker;
|
||||
|
||||
async execute() {
|
||||
const { argv } = this.options;
|
||||
const isDev = (process.argv[1]?.endsWith('.ts') || process.argv[1].includes('tinypool')) ?? false;
|
||||
const appRoot = process.env.APP_PACKAGE_ROOT || 'packages/core/app';
|
||||
const workerPath = path.resolve(process.cwd(), appRoot, isDev ? 'src/index.ts' : 'lib/index.js');
|
||||
|
||||
const workerPromise = new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.logger?.info(
|
||||
`Creating worker for task ${this.taskId} - path: ${workerPath}, argv: ${JSON.stringify(
|
||||
argv,
|
||||
)}, isDev: ${isDev}`,
|
||||
);
|
||||
|
||||
const worker = new Worker(workerPath, {
|
||||
execArgv: isDev ? ['--require', 'tsx/cjs'] : [],
|
||||
workerData: {
|
||||
argv,
|
||||
},
|
||||
});
|
||||
|
||||
this.workerThread = worker;
|
||||
this.logger?.debug(`Worker created successfully for task ${this.taskId}`);
|
||||
|
||||
let isCancelling = false;
|
||||
|
||||
// Listen for abort signal
|
||||
this.abortController.signal.addEventListener('abort', () => {
|
||||
isCancelling = true;
|
||||
this.logger?.info(`Terminating worker for task ${this.taskId} due to cancellation`);
|
||||
worker.terminate();
|
||||
});
|
||||
|
||||
worker.on('message', (message) => {
|
||||
this.logger?.debug(`Worker message received for task ${this.taskId} - type: ${message.type}`);
|
||||
if (message.type === 'progress') {
|
||||
this.reportProgress(message.payload);
|
||||
}
|
||||
|
||||
if (message.type === 'success') {
|
||||
this.logger?.info(
|
||||
`Worker completed successfully for task ${this.taskId} with payload: ${JSON.stringify(message.payload)}`,
|
||||
);
|
||||
resolve(message.payload);
|
||||
}
|
||||
});
|
||||
|
||||
worker.on('error', (error) => {
|
||||
this.logger?.error(`Worker error for task ${this.taskId}`, error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
worker.on('exit', (code) => {
|
||||
this.logger?.info(`Worker exited for task ${this.taskId} with code ${code}`);
|
||||
if (isCancelling) {
|
||||
reject(new CancelError());
|
||||
} else if (code !== 0) {
|
||||
reject(new Error(`Worker stopped with exit code ${code}`));
|
||||
} else {
|
||||
resolve(code);
|
||||
}
|
||||
});
|
||||
|
||||
worker.on('messageerror', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
return workerPromise;
|
||||
}
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
export * from './interfaces/async-task-manager';
|
||||
export * from './static-import';
|
||||
|
||||
export { default } from './plugin';
|
@ -0,0 +1,69 @@
|
||||
import { Logger } from '@nocobase/logger';
|
||||
import { ITask, TaskConstructor } from './task';
|
||||
import { Application } from '@nocobase/server';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export type TaskOptions = any;
|
||||
|
||||
export interface CreateTaskOptions {
|
||||
type: string;
|
||||
params: TaskOptions;
|
||||
tags?: Record<string, string>;
|
||||
title?: {
|
||||
actionType: string;
|
||||
collection: string;
|
||||
dataSource: string;
|
||||
};
|
||||
context?: any;
|
||||
}
|
||||
|
||||
export type TaskId = string;
|
||||
|
||||
export type TaskStatus = PendingStatus | SuccessStatus<any> | RunningStatus | FailedStatus | CancelledStatus;
|
||||
|
||||
export type ProgressIndicator = 'spinner' | 'progress' | 'success' | 'error';
|
||||
|
||||
export interface PendingStatus {
|
||||
type: 'pending';
|
||||
indicator?: 'spinner';
|
||||
}
|
||||
|
||||
export interface SuccessStatus<T = any> {
|
||||
type: 'success';
|
||||
indicator?: 'success';
|
||||
resultType?: 'file' | 'data';
|
||||
payload?: T;
|
||||
}
|
||||
|
||||
export interface RunningStatus {
|
||||
type: 'running';
|
||||
indicator: 'progress';
|
||||
}
|
||||
|
||||
export interface FailedStatus {
|
||||
type: 'failed';
|
||||
indicator?: 'error';
|
||||
errors: Array<{ message: string; code?: number }>;
|
||||
}
|
||||
|
||||
export interface CancelledStatus {
|
||||
type: 'cancelled';
|
||||
}
|
||||
|
||||
export interface AsyncTasksManager extends EventEmitter {
|
||||
setLogger(logger: Logger): void;
|
||||
setApp(app: Application): void;
|
||||
registerTaskType(taskType: TaskConstructor): void;
|
||||
createTask<T>(options: CreateTaskOptions): ITask;
|
||||
getTasksByTag(tagKey: string, tagValue: string): Promise<ITask[]>;
|
||||
cancelTask(taskId: TaskId): Promise<boolean>;
|
||||
getTaskStatus(taskId: TaskId): Promise<TaskStatus>;
|
||||
getTask(taskId: TaskId): ITask | undefined;
|
||||
}
|
||||
|
||||
export class CancelError extends Error {
|
||||
constructor(message = 'Task cancelled') {
|
||||
super(message);
|
||||
this.name = 'CancelError';
|
||||
}
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
import { Logger } from '@nocobase/logger';
|
||||
import { TaskStatus } from './async-task-manager';
|
||||
import { EventEmitter } from 'events';
|
||||
import { Application } from '@nocobase/server';
|
||||
|
||||
export interface ITask extends EventEmitter {
|
||||
taskId: string;
|
||||
status: TaskStatus;
|
||||
progress: {
|
||||
total: number;
|
||||
current: number;
|
||||
};
|
||||
startedAt: Date;
|
||||
fulfilledAt: Date;
|
||||
tags: Record<string, string>;
|
||||
createdAt: Date;
|
||||
title?: any;
|
||||
isCancelled: boolean;
|
||||
context?: any;
|
||||
setLogger(logger: Logger): void;
|
||||
setApp(app: Application): void;
|
||||
setContext(context: any): void;
|
||||
|
||||
cancel(): Promise<boolean>;
|
||||
execute(): Promise<any>;
|
||||
reportProgress(progress: { total: number; current: number }): void;
|
||||
run(): Promise<void>;
|
||||
toJSON(options?: { raw?: boolean }): any;
|
||||
}
|
||||
|
||||
export interface TaskConstructor {
|
||||
type: string;
|
||||
new (options: any, tags?: Record<string, string>): ITask;
|
||||
}
|
@ -0,0 +1,142 @@
|
||||
import { Plugin } from '@nocobase/server';
|
||||
import { BaseTaskManager } from './base-task-manager';
|
||||
import { AsyncTasksManager } from './interfaces/async-task-manager';
|
||||
import { CommandTaskType } from './command-task-type';
|
||||
import asyncTasksResource from './resourcers/async-tasks';
|
||||
|
||||
export class PluginAsyncExportServer extends Plugin {
|
||||
async afterAdd() {}
|
||||
|
||||
async beforeLoad() {
|
||||
this.app.container.register('AsyncTaskManager', () => {
|
||||
const manager = new BaseTaskManager();
|
||||
// @ts-ignore
|
||||
manager.setLogger(this.app.logger);
|
||||
manager.setApp(this.app);
|
||||
return manager;
|
||||
});
|
||||
|
||||
this.app.container.get<AsyncTasksManager>('AsyncTaskManager').registerTaskType(CommandTaskType);
|
||||
|
||||
this.app.acl.allow('asyncTasks', ['get', 'fetchFile'], 'loggedIn');
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.app.resourceManager.define(asyncTasksResource);
|
||||
|
||||
const asyncTaskManager = this.app.container.get<AsyncTasksManager>('AsyncTaskManager');
|
||||
|
||||
this.app.on(`ws:message:request:async-tasks:list`, async (message) => {
|
||||
const { tags } = message;
|
||||
|
||||
this.app.logger.info(`Received request for async tasks with tags: ${JSON.stringify(tags)}`);
|
||||
|
||||
const userTag = tags?.find((tag) => tag.startsWith('userId#'));
|
||||
const userId = userTag ? userTag.split('#')[1] : null;
|
||||
|
||||
if (userId) {
|
||||
this.app.logger.info(`Fetching tasks for userId: ${userId}`);
|
||||
|
||||
const tasks = await asyncTaskManager.getTasksByTag('userId', userId);
|
||||
|
||||
this.app.logger.info(`Found ${tasks.length} tasks for userId: ${userId}`);
|
||||
|
||||
this.app.emit('ws:sendToTag', {
|
||||
tagKey: 'userId',
|
||||
tagValue: userId,
|
||||
message: {
|
||||
type: 'async-tasks',
|
||||
payload: tasks.map((task) => task.toJSON()),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.app.logger.warn(`No userId found in message tags: ${JSON.stringify(tags)}`);
|
||||
}
|
||||
});
|
||||
|
||||
asyncTaskManager.on('taskCreated', ({ task }) => {
|
||||
const userId = task.tags['userId'];
|
||||
if (userId) {
|
||||
this.app.emit('ws:sendToTag', {
|
||||
tagKey: 'userId',
|
||||
tagValue: userId,
|
||||
message: {
|
||||
type: 'async-tasks:created',
|
||||
payload: task.toJSON(),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
asyncTaskManager.on('taskProgress', ({ task, progress }) => {
|
||||
const userId = task.tags['userId'];
|
||||
if (userId) {
|
||||
this.app.emit('ws:sendToTag', {
|
||||
tagKey: 'userId',
|
||||
tagValue: userId,
|
||||
message: {
|
||||
type: 'async-tasks:progress',
|
||||
payload: {
|
||||
taskId: task.taskId,
|
||||
progress,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
asyncTaskManager.on('taskStatusChange', ({ task, status }) => {
|
||||
const userId = task.tags['userId'];
|
||||
if (userId) {
|
||||
this.app.emit('ws:sendToTag', {
|
||||
tagKey: 'userId',
|
||||
tagValue: userId,
|
||||
message: {
|
||||
type: 'async-tasks:status',
|
||||
payload: {
|
||||
taskId: task.taskId,
|
||||
status: task.toJSON().status,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
asyncTaskManager.on('taskStatusChange', ({ status }) => {
|
||||
if (status.type === 'success') {
|
||||
this.app.emit('workflow:dispatch');
|
||||
}
|
||||
});
|
||||
|
||||
this.app.on('ws:message:request:async-tasks:cancel', async (message) => {
|
||||
const { payload, tags } = message;
|
||||
const { taskId } = payload;
|
||||
|
||||
const userTag = tags?.find((tag) => tag.startsWith('userId#'));
|
||||
const userId = userTag ? userTag.split('#')[1] : null;
|
||||
|
||||
if (userId) {
|
||||
const task = asyncTaskManager.getTask(taskId);
|
||||
|
||||
if (task.tags['userId'] != userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cancelled = await asyncTaskManager.cancelTask(taskId);
|
||||
|
||||
if (cancelled) {
|
||||
this.app.emit('ws:sendToTag', {
|
||||
tagKey: 'userId',
|
||||
tagValue: userId,
|
||||
message: {
|
||||
type: 'async-tasks:cancelled',
|
||||
payload: { taskId },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default PluginAsyncExportServer;
|
@ -0,0 +1,40 @@
|
||||
import fs from 'fs';
|
||||
import { basename } from 'path';
|
||||
export default {
|
||||
name: 'asyncTasks',
|
||||
actions: {
|
||||
async get(ctx, next) {
|
||||
const { filterByTk } = ctx.action.params;
|
||||
const taskManager = ctx.app.container.get('AsyncTaskManager');
|
||||
const taskStatus = await taskManager.getTaskStatus(filterByTk);
|
||||
|
||||
ctx.body = taskStatus;
|
||||
await next();
|
||||
},
|
||||
async fetchFile(ctx, next) {
|
||||
const { filterByTk } = ctx.action.params;
|
||||
const taskManager = ctx.app.container.get('AsyncTaskManager');
|
||||
const taskStatus = await taskManager.getTaskStatus(filterByTk);
|
||||
// throw error if task is not success
|
||||
if (taskStatus.type !== 'success') {
|
||||
throw new Error('Task is not success status');
|
||||
}
|
||||
|
||||
const { filePath } = taskStatus.payload;
|
||||
|
||||
if (!filePath) {
|
||||
throw new Error('not a file task');
|
||||
}
|
||||
|
||||
// send file to client
|
||||
ctx.body = fs.createReadStream(filePath);
|
||||
|
||||
ctx.set({
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename=${basename(filePath)}`,
|
||||
});
|
||||
|
||||
await next();
|
||||
},
|
||||
},
|
||||
};
|
@ -0,0 +1,5 @@
|
||||
import { appendToBuiltInPlugins } from '@nocobase/server';
|
||||
|
||||
export async function staticImport() {
|
||||
await appendToBuiltInPlugins('@nocobase/plugin-async-task-manager');
|
||||
}
|
@ -0,0 +1,194 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import EventEmitter from 'events';
|
||||
import { AbortController } from 'abort-controller';
|
||||
import { Logger } from '@nocobase/logger';
|
||||
import { TaskOptions, TaskStatus, CancelError } from './interfaces/async-task-manager';
|
||||
import { ITask } from './interfaces/task';
|
||||
import Application from '@nocobase/server';
|
||||
import PluginErrorHandler, { ErrorHandler } from '@nocobase/plugin-error-handler';
|
||||
|
||||
export abstract class TaskType extends EventEmitter implements ITask {
|
||||
static type: string;
|
||||
|
||||
public status: TaskStatus;
|
||||
protected logger: Logger;
|
||||
protected app: Application;
|
||||
|
||||
public progress: {
|
||||
total: number;
|
||||
current: number;
|
||||
} = {
|
||||
total: 0,
|
||||
current: 0,
|
||||
};
|
||||
|
||||
public startedAt: Date;
|
||||
public fulfilledAt: Date;
|
||||
public taskId: string;
|
||||
public tags: Record<string, string>;
|
||||
public createdAt: Date;
|
||||
public context?: any;
|
||||
public title;
|
||||
protected abortController: AbortController = new AbortController();
|
||||
|
||||
private _isCancelled = false;
|
||||
|
||||
get isCancelled() {
|
||||
return this._isCancelled;
|
||||
}
|
||||
|
||||
constructor(
|
||||
protected options: TaskOptions,
|
||||
tags?: Record<string, string>,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.status = {
|
||||
type: 'pending',
|
||||
indicator: 'spinner',
|
||||
};
|
||||
|
||||
this.taskId = uuidv4();
|
||||
this.tags = tags || {};
|
||||
this.createdAt = new Date();
|
||||
}
|
||||
|
||||
setLogger(logger: Logger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
setApp(app: Application) {
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
setContext(context: any) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the task
|
||||
*/
|
||||
async cancel() {
|
||||
this._isCancelled = true;
|
||||
this.abortController.abort();
|
||||
this.logger?.debug(`Task ${this.taskId} cancelled`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the task implementation
|
||||
* @returns Promise that resolves with the task result
|
||||
*/
|
||||
abstract execute(): Promise<any>;
|
||||
|
||||
/**
|
||||
* Report task progress
|
||||
* @param progress Progress information containing total and current values
|
||||
*/
|
||||
reportProgress(progress: { total: number; current: number }) {
|
||||
this.progress = progress;
|
||||
this.logger?.debug(`Task ${this.taskId} progress update - current: ${progress.current}, total: ${progress.total}`);
|
||||
this.emit('progress', progress);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the task
|
||||
* This method handles task lifecycle, including:
|
||||
* - Status management
|
||||
* - Error handling
|
||||
* - Progress tracking
|
||||
* - Event emission
|
||||
*/
|
||||
async run() {
|
||||
this.startedAt = new Date();
|
||||
this.logger?.info(`Starting task ${this.taskId}, type: ${(this.constructor as typeof TaskType).type}`);
|
||||
|
||||
this.status = {
|
||||
type: 'running',
|
||||
indicator: 'progress',
|
||||
};
|
||||
|
||||
this.emit('statusChange', this.status);
|
||||
|
||||
try {
|
||||
if (this._isCancelled) {
|
||||
this.logger?.info(`Task ${this.taskId} was cancelled before execution`);
|
||||
this.status = {
|
||||
type: 'cancelled',
|
||||
};
|
||||
this.emit('statusChange', this.status);
|
||||
return;
|
||||
}
|
||||
|
||||
const executePromise = this.execute();
|
||||
const result = await executePromise;
|
||||
|
||||
this.status = {
|
||||
type: 'success',
|
||||
indicator: 'success',
|
||||
payload: result,
|
||||
};
|
||||
|
||||
this.logger?.info(`Task ${this.taskId} completed successfully with result: ${JSON.stringify(result)}`);
|
||||
this.emit('statusChange', this.status);
|
||||
} catch (error) {
|
||||
if (error instanceof CancelError) {
|
||||
this.status = {
|
||||
type: 'cancelled',
|
||||
};
|
||||
this.logger?.info(`Task ${this.taskId} was cancelled during execution`);
|
||||
} else {
|
||||
this.status = {
|
||||
type: 'failed',
|
||||
indicator: 'error',
|
||||
errors: [{ message: this.renderErrorMessage(error) }],
|
||||
};
|
||||
|
||||
this.logger?.error(`Task ${this.taskId} failed with error: ${error.message}`);
|
||||
}
|
||||
|
||||
this.emit('statusChange', this.status);
|
||||
} finally {
|
||||
this.fulfilledAt = new Date();
|
||||
const duration = this.fulfilledAt.getTime() - this.startedAt.getTime();
|
||||
this.logger?.info(`Task ${this.taskId} finished in ${duration}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
private renderErrorMessage(error: Error) {
|
||||
const errorHandlerPlugin = this.app.pm.get('error-handler') as PluginErrorHandler;
|
||||
if (!errorHandlerPlugin || !this.context) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
const errorHandler: ErrorHandler = errorHandlerPlugin.errorHandler;
|
||||
|
||||
errorHandler.renderError(error, this.context);
|
||||
return this.context.body.errors[0].message;
|
||||
}
|
||||
|
||||
toJSON(options?: { raw?: boolean }) {
|
||||
const json = {
|
||||
taskId: this.taskId,
|
||||
status: { ...this.status },
|
||||
progress: this.progress,
|
||||
tags: this.tags,
|
||||
createdAt: this.createdAt,
|
||||
startedAt: this.startedAt,
|
||||
fulfilledAt: this.fulfilledAt,
|
||||
title: this.title,
|
||||
};
|
||||
|
||||
// If not in raw mode and the status is success with a file path, transform the status format
|
||||
if (!options?.raw && json.status.type === 'success' && json.status.payload?.filePath) {
|
||||
json.status = {
|
||||
type: 'success',
|
||||
indicator: 'success',
|
||||
resultType: 'file',
|
||||
payload: {},
|
||||
};
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user