@adminforth/background-jobs 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.woodpecker/buildRelease.sh +13 -0
- package/.woodpecker/buildSlackNotify.sh +46 -0
- package/.woodpecker/release.yml +57 -0
- package/LICENSE +21 -0
- package/build.log +15 -0
- package/custom/JobInfoPopup.vue +110 -0
- package/custom/JobsList.vue +53 -0
- package/custom/NavbarJobs.vue +154 -0
- package/custom/StateToIcon.vue +40 -0
- package/custom/tsconfig.json +16 -0
- package/custom/utils.ts +9 -0
- package/dist/custom/JobInfoPopup.vue +110 -0
- package/dist/custom/JobsList.vue +53 -0
- package/dist/custom/NavbarJobs.vue +154 -0
- package/dist/custom/StateToIcon.vue +40 -0
- package/dist/custom/tsconfig.json +16 -0
- package/dist/custom/utils.ts +9 -0
- package/dist/index.js +433 -0
- package/dist/types.js +1 -0
- package/index.ts +459 -0
- package/package.json +27 -0
- package/tsconfig.json +13 -0
- package/types.ts +15 -0
package/index.ts
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { AdminForthPlugin, Filters, Sorts } from "adminforth";
|
|
2
|
+
import type { IAdminForth, IHttpServer, AdminForthResourcePages, AdminForthResourceColumn, AdminForthDataTypes, AdminForthResource, AdminUser, AdminForthComponentDeclarationFull } from "adminforth";
|
|
3
|
+
import type { PluginOptions } from './types.js';
|
|
4
|
+
import { afLogger } from "adminforth";
|
|
5
|
+
import pLimit from 'p-limit';
|
|
6
|
+
import { Level } from 'level';
|
|
7
|
+
|
|
8
|
+
type TaskStatus = 'SCHEDULED' | 'IN_PROGRESS' | 'DONE' | 'FAILED';
|
|
9
|
+
type setStateFieldParams = (state: Record<string, any>) => void;
|
|
10
|
+
type getStateFieldParams = () => any;
|
|
11
|
+
type taskHandlerType = ( { setTaskStateField, getTaskStateField }: { setTaskStateField: setStateFieldParams; getTaskStateField: getStateFieldParams } ) => Promise<void>;
|
|
12
|
+
type taskType = {
|
|
13
|
+
skip?: boolean;
|
|
14
|
+
state: Record<string, any>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default class extends AdminForthPlugin {
|
|
18
|
+
options: PluginOptions;
|
|
19
|
+
private taskHandlers: Record<string, taskHandlerType> = {};
|
|
20
|
+
private jobCustomComponents: Record<string, AdminForthComponentDeclarationFull> = {};
|
|
21
|
+
private jobParallelLimits: Record<string, number> = {};
|
|
22
|
+
private levelDbInstances: Record<string, Level> = {};
|
|
23
|
+
|
|
24
|
+
constructor(options: PluginOptions) {
|
|
25
|
+
super(options, import.meta.url);
|
|
26
|
+
this.options = options;
|
|
27
|
+
this.shouldHaveSingleInstancePerWholeApp = () => true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private getResourcePk(): string {
|
|
31
|
+
const resourcePk = this.resourceConfig.columns.find(c => c.primaryKey)?.name;
|
|
32
|
+
return resourcePk;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private getResourceId(): string {
|
|
36
|
+
return this.resourceConfig.resourceId;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
|
|
40
|
+
super.modifyResourceConfig(adminforth, resourceConfig);
|
|
41
|
+
|
|
42
|
+
if (!adminforth.config.customization?.globalInjections?.header) {
|
|
43
|
+
adminforth.config.customization.globalInjections.header = [];
|
|
44
|
+
}
|
|
45
|
+
(adminforth.config.customization.globalInjections.header).push({
|
|
46
|
+
file: this.componentPath('NavbarJobs.vue'),
|
|
47
|
+
meta: {
|
|
48
|
+
pluginInstanceId: this.pluginInstanceId,
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private checkIfFieldInResource(resourceConfig: AdminForthResource, fieldName: string, fieldString?: string) {
|
|
54
|
+
if (!fieldName) {
|
|
55
|
+
throw new Error(`Field name for ${fieldString} is not provided. Please check your plugin options.`);
|
|
56
|
+
}
|
|
57
|
+
const fieldInConfig = resourceConfig.columns.find(f => f.name === fieldName);
|
|
58
|
+
if (!fieldInConfig) {
|
|
59
|
+
throw new Error(`Field ${fieldName} not found in resource config. Please check your plugin options.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private async createLevelDbTaskRecord(levelDb: Level, taskId: string, initialState: Record<string, any>) {
|
|
64
|
+
//create record in level db with task id as key and initial state as value and status SCHEDULED
|
|
65
|
+
await levelDb.put(taskId, JSON.stringify({ state: initialState, status: 'SCHEDULED' }));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private async setLevelDbTaskStateField(levelDb: Level, taskId: string, state: Record<string, any>) {
|
|
69
|
+
//update record in level db with task id as key and new state as value
|
|
70
|
+
const status = await this.getLevelDbTaskStatusField(levelDb, taskId);
|
|
71
|
+
await levelDb.del(taskId);
|
|
72
|
+
await levelDb.put(taskId, JSON.stringify({ state, status }));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async setLevelDbTaskStatusField(levelDb: Level, taskId: string, status: TaskStatus) {
|
|
76
|
+
const state = await this.getLevelDbTaskStateField(levelDb, taskId);
|
|
77
|
+
await levelDb.del(taskId);
|
|
78
|
+
await levelDb.put(taskId, JSON.stringify({ state, status }));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private async getLevelDbTaskStateField(levelDb: Level, taskId: string): Promise<Record<string, any>> {
|
|
82
|
+
//get record from level db with task id as key and return the value of the key in the state
|
|
83
|
+
const state = await levelDb.get(taskId);
|
|
84
|
+
if (state) {
|
|
85
|
+
const parsedState = JSON.parse(state);
|
|
86
|
+
return parsedState.state;
|
|
87
|
+
}
|
|
88
|
+
return Promise.resolve(null);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private async getLevelDbTaskStatusField(levelDb: Level, taskId: string): Promise<TaskStatus> {
|
|
92
|
+
const state = await levelDb.get(taskId);
|
|
93
|
+
if (state) {
|
|
94
|
+
const parsedState = JSON.parse(state);
|
|
95
|
+
return parsedState.status;
|
|
96
|
+
}
|
|
97
|
+
return Promise.resolve(null);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
public registerTaskHandler(
|
|
101
|
+
jobHandlerName: string,
|
|
102
|
+
handler: taskHandlerType,
|
|
103
|
+
customComponent?: AdminForthComponentDeclarationFull,
|
|
104
|
+
parrallelLimit: number = 3,
|
|
105
|
+
) {
|
|
106
|
+
//register the handler in a map with jobHandlerName as key and handler as value
|
|
107
|
+
this.taskHandlers[jobHandlerName] = handler;
|
|
108
|
+
this.jobParallelLimits[jobHandlerName] = parrallelLimit;
|
|
109
|
+
if (customComponent) {
|
|
110
|
+
this.jobCustomComponents[jobHandlerName] = customComponent;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
public async startNewJob(
|
|
115
|
+
jobName: string,
|
|
116
|
+
adminUser: AdminUser,
|
|
117
|
+
tasks: taskType[],
|
|
118
|
+
initialFields: Record<string, any> = {},
|
|
119
|
+
jobHandlerName: string,
|
|
120
|
+
) {
|
|
121
|
+
|
|
122
|
+
const handleTask: taskHandlerType = this.taskHandlers[jobHandlerName];
|
|
123
|
+
if (!handleTask) {
|
|
124
|
+
throw new Error(`No handler registered for jobHandler ${jobHandlerName}. Please register a handler using the registerTaskHandler method before starting a job with this jobHandler.`);
|
|
125
|
+
}
|
|
126
|
+
const customComponent = this.jobCustomComponents[jobHandlerName];
|
|
127
|
+
const parrallelLimit = this.jobParallelLimits[jobHandlerName] || 3;
|
|
128
|
+
//create a record for the job in the database with status in progress
|
|
129
|
+
const objectToSave = {
|
|
130
|
+
[this.options.nameField]: jobName,
|
|
131
|
+
[this.options.startedByField]: adminUser.pk,
|
|
132
|
+
[this.options.stateField]: JSON.stringify(initialFields),
|
|
133
|
+
[this.options.progressField]: 0,
|
|
134
|
+
[this.options.statusField]: 'IN_PROGRESS',
|
|
135
|
+
[this.options.jobHandlerField]: jobHandlerName,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const creationResult = await this.adminforth.resource(this.getResourceId()).create(objectToSave);
|
|
139
|
+
let createdRecord: Record<string, any> = null;
|
|
140
|
+
if (creationResult.ok === true ) {
|
|
141
|
+
createdRecord = creationResult.createdRecord;
|
|
142
|
+
} else {
|
|
143
|
+
throw new Error(`Failed to create a record for the job. Error: ${creationResult.error}`);
|
|
144
|
+
}
|
|
145
|
+
const jobId = createdRecord[this.getResourcePk()];
|
|
146
|
+
|
|
147
|
+
this.adminforth.websocket.publish('/background-jobs', {
|
|
148
|
+
jobId,
|
|
149
|
+
status: 'IN_PROGRESS',
|
|
150
|
+
name: jobName,
|
|
151
|
+
progress: 0,
|
|
152
|
+
createdAt: createdRecord[this.options.createdAtField],
|
|
153
|
+
customComponent,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
//create a level db instance for the job with name as jobId
|
|
157
|
+
const jobLevelDb = new Level(`${this.options.levelDbPath || './background-jobs-dbs/'}job_${jobId}`, { valueEncoding: 'json' });
|
|
158
|
+
this.levelDbInstances[jobId] = jobLevelDb;
|
|
159
|
+
|
|
160
|
+
const limit2 = pLimit(parrallelLimit);
|
|
161
|
+
const createTaskRecordsPromises = tasks.map((task, index) => {
|
|
162
|
+
return limit2(() => this.createLevelDbTaskRecord(jobLevelDb, index.toString(), task.state));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await Promise.all(createTaskRecordsPromises);
|
|
166
|
+
|
|
167
|
+
this.runProcessingTasks(tasks, jobLevelDb, jobId, handleTask, parrallelLimit);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private async runProcessingTasks(
|
|
171
|
+
tasks: taskType[],
|
|
172
|
+
jobLevelDb: Level,
|
|
173
|
+
jobId: string,
|
|
174
|
+
handleTask: taskHandlerType,
|
|
175
|
+
parrallelLimit: number,
|
|
176
|
+
) {
|
|
177
|
+
const totalTasks = tasks.length;
|
|
178
|
+
let completedTasks = 0;
|
|
179
|
+
let failedTasks = 0;
|
|
180
|
+
let lastJobStatus = 'IN_PROGRESS';
|
|
181
|
+
|
|
182
|
+
const taskHandler = async ( taskIndex: number, task ) => {
|
|
183
|
+
if (task.skip) {
|
|
184
|
+
completedTasks = await this.handleFinishTask(completedTasks, totalTasks, jobId, true);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (lastJobStatus === 'CANCELLED') {
|
|
188
|
+
afLogger.info(`Job ${jobId} was cancelled. Skipping task ${taskIndex}.`);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const currentJobStatus = await this.getLastJobStatus(jobId);
|
|
192
|
+
|
|
193
|
+
if (currentJobStatus === 'CANCELLED') {
|
|
194
|
+
lastJobStatus = currentJobStatus;
|
|
195
|
+
afLogger.info(`Job ${jobId} was cancelled. Skipping task ${taskIndex}.`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
//define the setTaskStateField and getTaskStateField functions to pass to the task
|
|
200
|
+
const setTaskStateField = async (state: Record<string, any>) => {
|
|
201
|
+
await this.setLevelDbTaskStateField(jobLevelDb, taskIndex.toString(), state);
|
|
202
|
+
}
|
|
203
|
+
const getTaskStateField = async () => {
|
|
204
|
+
return await this.getLevelDbTaskStateField(jobLevelDb, taskIndex.toString());
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
await this.setLevelDbTaskStatusField(jobLevelDb, taskIndex.toString(), 'IN_PROGRESS');
|
|
208
|
+
this.adminforth.websocket.publish(`/background-jobs-task-update/${jobId}`, { taskIndex, status: "IN_PROGRESS" });
|
|
209
|
+
|
|
210
|
+
//handling the task
|
|
211
|
+
try {
|
|
212
|
+
await handleTask({ setTaskStateField, getTaskStateField });
|
|
213
|
+
|
|
214
|
+
//Set task status to completed in level db
|
|
215
|
+
await this.setLevelDbTaskStatusField(jobLevelDb, taskIndex.toString(), 'DONE');
|
|
216
|
+
this.adminforth.websocket.publish(`/background-jobs-task-update/${jobId}`, { taskIndex, status: "DONE" });
|
|
217
|
+
} catch (error) {
|
|
218
|
+
afLogger.error(`Error in handling task ${taskIndex} of job ${jobId}: ${error}`, );
|
|
219
|
+
await this.setLevelDbTaskStatusField(jobLevelDb, taskIndex.toString(), 'FAILED');
|
|
220
|
+
this.adminforth.websocket.publish(`/background-jobs-task-update/${jobId}`, { taskIndex, status: "FAILED" });
|
|
221
|
+
failedTasks++;
|
|
222
|
+
return;
|
|
223
|
+
} finally {
|
|
224
|
+
//Update progress
|
|
225
|
+
const currentJobStatus = await this.getLastJobStatus(jobId);
|
|
226
|
+
if (currentJobStatus === 'CANCELLED') {
|
|
227
|
+
lastJobStatus = currentJobStatus;
|
|
228
|
+
afLogger.debug(`Job ${jobId} was cancelled during processing of task ${taskIndex}. Progress will not be updated.`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
completedTasks = await this.handleFinishTask(completedTasks, totalTasks, jobId);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const limit = pLimit(parrallelLimit);
|
|
237
|
+
const tasksToExecute = tasks.map((task, taskIndex) => {
|
|
238
|
+
return limit(() => taskHandler(taskIndex, task));
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await Promise.all(tasksToExecute);
|
|
242
|
+
if (lastJobStatus !== 'CANCELLED' && failedTasks === 0) {
|
|
243
|
+
await this.adminforth.resource(this.getResourceId()).update(jobId, {
|
|
244
|
+
[this.options.statusField]: 'DONE',
|
|
245
|
+
})
|
|
246
|
+
this.adminforth.websocket.publish('/background-jobs', { jobId, status: 'DONE' });
|
|
247
|
+
} else if (failedTasks > 0) {
|
|
248
|
+
await this.adminforth.resource(this.getResourceId()).update(jobId, {
|
|
249
|
+
[this.options.statusField]: 'DONE_WITH_ERRORS',
|
|
250
|
+
})
|
|
251
|
+
this.adminforth.websocket.publish('/background-jobs', { jobId, status: 'DONE_WITH_ERRORS' });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private async getLastJobStatus(jobId: string): Promise<string> {
|
|
256
|
+
const currentJobRecord = await this.adminforth.resource(this.getResourceId()).get(Filters.EQ(this.getResourcePk(), jobId));
|
|
257
|
+
const currentJobStatus = currentJobRecord[this.options.statusField];
|
|
258
|
+
return currentJobStatus;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async handleFinishTask(completedTasks: number, totalTasks: number, jobId: string, wasTaskSkipped: boolean = false) {
|
|
262
|
+
completedTasks++;
|
|
263
|
+
if (wasTaskSkipped) {
|
|
264
|
+
return completedTasks;
|
|
265
|
+
}
|
|
266
|
+
const progress = Math.round((completedTasks / totalTasks) * 100);
|
|
267
|
+
await this.adminforth.resource(this.getResourceId()).update(jobId, {
|
|
268
|
+
[this.options.progressField]: progress,
|
|
269
|
+
})
|
|
270
|
+
this.adminforth.websocket.publish('/background-jobs', { jobId, progress });
|
|
271
|
+
return completedTasks;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
private async runProcessingUnfinishedTasks(
|
|
276
|
+
job: Record<string, any>
|
|
277
|
+
) {
|
|
278
|
+
const levelDbPath = `${this.options.levelDbPath || './background-jobs-dbs/'}job_${job[this.getResourcePk()]}`;
|
|
279
|
+
const jobLevelDb = new Level(levelDbPath, { valueEncoding: 'json' });
|
|
280
|
+
this.levelDbInstances[job[this.getResourcePk()]] = jobLevelDb;
|
|
281
|
+
const jobHandlerName = job[this.options.jobHandlerField];
|
|
282
|
+
const handleTask: taskHandlerType = this.taskHandlers[jobHandlerName];
|
|
283
|
+
if (!handleTask) {
|
|
284
|
+
afLogger.error(`No handler registered for jobHandler ${jobHandlerName}. Cannot process unfinished tasks for job ${job[this.getResourcePk()]}.`);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const parrallelLimit = this.jobParallelLimits[jobHandlerName] || 3;
|
|
288
|
+
|
|
289
|
+
const unfinishedTasks: taskType[] = [];
|
|
290
|
+
let taskIndex = 0;
|
|
291
|
+
while (true) {
|
|
292
|
+
const taskData = await jobLevelDb.get(taskIndex.toString());
|
|
293
|
+
if (!taskData) {
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
let parsedTaskData: { state: Record<string, any>, status: TaskStatus };
|
|
297
|
+
try {
|
|
298
|
+
parsedTaskData = JSON.parse(taskData);
|
|
299
|
+
} catch (error) {
|
|
300
|
+
afLogger.error(`Error parsing task data for task ${taskIndex} of job ${job[this.getResourcePk()]}: ${error}`);
|
|
301
|
+
taskIndex++;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (parsedTaskData.status === 'IN_PROGRESS' || parsedTaskData.status === 'SCHEDULED') {
|
|
305
|
+
unfinishedTasks.push({ state: parsedTaskData.state });
|
|
306
|
+
} else {
|
|
307
|
+
unfinishedTasks.push({ state: parsedTaskData.state, skip: true });
|
|
308
|
+
}
|
|
309
|
+
taskIndex++;
|
|
310
|
+
}
|
|
311
|
+
await this.runProcessingTasks(unfinishedTasks, jobLevelDb, job[this.getResourcePk()], handleTask, parrallelLimit);
|
|
312
|
+
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
public async setJobField(jobId: string, key: string, value: any) {
|
|
316
|
+
const jobRecord = await this.adminforth.resource(this.getResourceId()).get(Filters.EQ(this.getResourcePk(), jobId));
|
|
317
|
+
const state = jobRecord[this.options.stateField];
|
|
318
|
+
const parsedState = JSON.parse(state);
|
|
319
|
+
parsedState[key] = value;
|
|
320
|
+
await this.adminforth.resource(this.getResourceId()).update(jobId, {
|
|
321
|
+
[this.options.stateField]: JSON.stringify(parsedState),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
public async getJobField(jobId: string, key: string) {
|
|
326
|
+
const jobRecord = await this.adminforth.resource(this.getResourceId()).get(Filters.EQ(this.getResourcePk(), jobId));
|
|
327
|
+
const state = jobRecord[this.options.stateField];
|
|
328
|
+
const parsedState = JSON.parse(state);
|
|
329
|
+
return parsedState[key];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
public async getJobState(jobId: string) {
|
|
333
|
+
const jobRecord = await this.adminforth.resource(this.getResourceId()).get(Filters.EQ(this.getResourcePk(), jobId));
|
|
334
|
+
const state = jobRecord[this.options.stateField];
|
|
335
|
+
return JSON.parse(state);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private async processAllUnfinishedJobs() {
|
|
339
|
+
const resourceId = this.getResourceId();
|
|
340
|
+
const unprocessedJobs = await this.adminforth.resource(resourceId).list(Filters.EQ(this.options.statusField, 'IN_PROGRESS'));
|
|
341
|
+
for (const job of unprocessedJobs) {
|
|
342
|
+
const jobName = job[this.options.nameField];
|
|
343
|
+
afLogger.info(`Processing unfinished job with name ${jobName} on startup.`);
|
|
344
|
+
this.runProcessingUnfinishedTasks(job);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
async validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
|
|
350
|
+
// optional method where you can safely check field types after database discovery was performed
|
|
351
|
+
this.checkIfFieldInResource(resourceConfig, this.options.createdAtField, 'createdAtField');
|
|
352
|
+
this.checkIfFieldInResource(resourceConfig, this.options.startedByField, 'startedByField');
|
|
353
|
+
this.checkIfFieldInResource(resourceConfig, this.options.stateField, 'stateField');
|
|
354
|
+
this.checkIfFieldInResource(resourceConfig, this.options.progressField, 'progressField');
|
|
355
|
+
this.checkIfFieldInResource(resourceConfig, this.options.statusField, 'statusField');
|
|
356
|
+
this.checkIfFieldInResource(resourceConfig, this.options.nameField, 'nameField');
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
//Add temp delay to make sure, that all resources active. Probably should be fixed
|
|
360
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
361
|
+
this.processAllUnfinishedJobs();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
instanceUniqueRepresentation(pluginOptions: any) : string {
|
|
365
|
+
return `BackgroundJobsPlugin`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
setupEndpoints(server: IHttpServer) {
|
|
369
|
+
server.endpoint({
|
|
370
|
+
method: 'POST',
|
|
371
|
+
path: `/plugin/${this.pluginInstanceId}/get-list-of-jobs`,
|
|
372
|
+
handler: async ({ adminUser }) => {
|
|
373
|
+
const user = adminUser;
|
|
374
|
+
const startedByField = this.options.startedByField;
|
|
375
|
+
const resourcePk = this.getResourcePk();
|
|
376
|
+
const listOfJobs = await this.adminforth.resource(this.resourceConfig.resourceId).list(Filters.EQ(startedByField, user.pk), 100, 0, Sorts.DESC(this.options.createdAtField));
|
|
377
|
+
|
|
378
|
+
const jobsToReturn = listOfJobs.map(job => {
|
|
379
|
+
return {
|
|
380
|
+
id: job[resourcePk],
|
|
381
|
+
name: job[this.options.nameField],
|
|
382
|
+
createdAt: job[this.options.createdAtField],
|
|
383
|
+
status: job[this.options.statusField],
|
|
384
|
+
progress: job[this.options.progressField],
|
|
385
|
+
customComponent: this.jobCustomComponents[job[this.options.jobHandlerField]],
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
return { jobs: jobsToReturn };
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
server.endpoint({
|
|
393
|
+
method: 'POST',
|
|
394
|
+
path: `/plugin/${this.pluginInstanceId}/cancel-job`,
|
|
395
|
+
handler: async ({ body }) => {
|
|
396
|
+
const jobId = body.jobId;
|
|
397
|
+
const currentJob = await this.adminforth.resource(this.getResourceId()).get(Filters.EQ(this.getResourcePk(), jobId));
|
|
398
|
+
const oldStatus = currentJob[this.options.statusField];
|
|
399
|
+
if (oldStatus === 'DONE' || oldStatus === 'DONE_WITH_ERRORS' || oldStatus === 'CANCELLED') {
|
|
400
|
+
return { ok: false, message: `Cannot cancel a job with status ${oldStatus}.` };
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
await this.adminforth.resource(this.getResourceId()).update(jobId, {
|
|
404
|
+
[this.options.statusField]: 'CANCELLED',
|
|
405
|
+
});
|
|
406
|
+
this.adminforth.websocket.publish('/background-jobs', {
|
|
407
|
+
jobId,
|
|
408
|
+
status: 'CANCELLED',
|
|
409
|
+
});
|
|
410
|
+
return { ok: true };
|
|
411
|
+
} catch (error) {
|
|
412
|
+
return { ok: false, message: `Failed to cancel job with id ${jobId}.` };
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
server.endpoint({
|
|
418
|
+
method: 'POST',
|
|
419
|
+
path: `/plugin/${this.pluginInstanceId}/get-tasks`,
|
|
420
|
+
handler: async ({ body }) => {
|
|
421
|
+
const { jobId, limit, offset } = body;
|
|
422
|
+
const levelDbPath = `${this.options.levelDbPath || './background-jobs-dbs/'}job_${jobId}`;
|
|
423
|
+
let jobLevelDb: Level;
|
|
424
|
+
if (this.levelDbInstances[jobId]) {
|
|
425
|
+
jobLevelDb = this.levelDbInstances[jobId];
|
|
426
|
+
} else {
|
|
427
|
+
try {
|
|
428
|
+
jobLevelDb = new Level(levelDbPath, { valueEncoding: 'json' });
|
|
429
|
+
} catch (error) {
|
|
430
|
+
return { ok: false, message: `Failed to access tasks for job with id ${jobId}.` };
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const tasks = [];
|
|
434
|
+
let taskIndex = 0 + offset;
|
|
435
|
+
while (true) {
|
|
436
|
+
if (limit && tasks.length >= limit) {
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
const taskData = await jobLevelDb.get(taskIndex.toString());
|
|
440
|
+
if (!taskData) {
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
let parsedTaskData: { state: Record<string, any>, status: TaskStatus };
|
|
444
|
+
try {
|
|
445
|
+
parsedTaskData = JSON.parse(taskData);
|
|
446
|
+
} catch (error) {
|
|
447
|
+
afLogger.error(`Error parsing task data for task ${taskIndex} of job ${jobId}: ${error}`);
|
|
448
|
+
taskIndex++;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
tasks.push(parsedTaskData);
|
|
452
|
+
taskIndex++;
|
|
453
|
+
}
|
|
454
|
+
return { ok: true, tasks };
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@adminforth/background-jobs",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"types": "dist/index.d.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc && rsync -av --exclude 'node_modules' custom dist/"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [],
|
|
14
|
+
"author": "",
|
|
15
|
+
"license": "ISC",
|
|
16
|
+
"description": "",
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "latest",
|
|
19
|
+
"typescript": "^5.7.3"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@vueuse/core": "^14.2.1",
|
|
23
|
+
"adminforth": "latest",
|
|
24
|
+
"level": "^10.0.0",
|
|
25
|
+
"p-limit": "^7.3.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include*/
|
|
4
|
+
"module": "node16", /* Specify what module code is generated. */
|
|
5
|
+
"outDir": "./dist", /* Specify an output folder for all emitted files. */
|
|
6
|
+
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. */
|
|
7
|
+
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
|
8
|
+
"strict": false, /* Enable all strict type-checking options. */
|
|
9
|
+
"skipLibCheck": true, /* Skip type checking all .d.ts files. */
|
|
10
|
+
},
|
|
11
|
+
"exclude": ["node_modules", "dist", "custom"], /* Exclude files from compilation. */
|
|
12
|
+
}
|
|
13
|
+
|
package/types.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
|
|
2
|
+
export interface PluginOptions {
|
|
3
|
+
createdAtField: string;
|
|
4
|
+
startedByField: string;
|
|
5
|
+
stateField: string;
|
|
6
|
+
progressField: string;
|
|
7
|
+
statusField: string;
|
|
8
|
+
nameField?: string;
|
|
9
|
+
jobHandlerField?: string;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Path to the level db folder. If not provided, a default path is ./background-jobs-dbs/
|
|
13
|
+
*/
|
|
14
|
+
levelDbPath?: string;
|
|
15
|
+
}
|