Skip to content

Commit 4688202

Browse files
committed
performance improvements
1 parent b8db96d commit 4688202

File tree

9 files changed

+465
-134
lines changed

9 files changed

+465
-134
lines changed
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
'use strict';
2+
3+
const Archetype = require('archetype');
4+
5+
const GetTaskOverviewParams = new Archetype({
6+
start: { $type: Date },
7+
end: { $type: Date },
8+
status: { $type: 'string' },
9+
name: { $type: 'string' }
10+
}).compile('GetTaskOverviewParams');
11+
12+
/** Statuses shown on the Task overview page. */
13+
const OVERVIEW_STATUSES = ['pending', 'succeeded', 'failed', 'cancelled'];
14+
15+
function ensureDate(value) {
16+
if (value == null) return value;
17+
if (value instanceof Date) return value;
18+
if (typeof value === 'string' || typeof value === 'number') {
19+
const d = new Date(value);
20+
if (!Number.isNaN(d.getTime())) return d;
21+
}
22+
return value;
23+
}
24+
25+
function escapeRegex(str) {
26+
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
27+
}
28+
29+
function buildMatch(params) {
30+
const { start, end, status, name } = params;
31+
const match = {};
32+
const startDate = ensureDate(start);
33+
const endDate = ensureDate(end);
34+
if (startDate != null && endDate != null) {
35+
match.scheduledAt = { $gte: startDate, $lt: endDate };
36+
} else if (startDate != null) {
37+
match.scheduledAt = { $gte: startDate };
38+
}
39+
const statusVal = typeof status === 'string' ? status.trim() : status;
40+
if (statusVal != null && statusVal !== '') {
41+
match.status = statusVal;
42+
} else {
43+
match.status = { $in: ['pending', 'in_progress', 'succeeded', 'failed', 'cancelled', 'unknown'] };
44+
}
45+
if (name != null && name !== '') {
46+
const nameStr = typeof name === 'string' ? name.trim() : String(name);
47+
match.name = { $regex: escapeRegex(nameStr), $options: 'i' };
48+
}
49+
return match;
50+
}
51+
52+
module.exports = ({ db }) => async function getTaskOverview(params) {
53+
params = new GetTaskOverviewParams(params);
54+
params.start = ensureDate(params.start);
55+
params.end = ensureDate(params.end);
56+
if (typeof params.status === 'string') params.status = params.status.trim();
57+
if (typeof params.name === 'string') params.name = params.name.trim();
58+
const { Task } = db.models;
59+
const match = buildMatch(params);
60+
61+
const defaultCounts = OVERVIEW_STATUSES.map(s => ({ k: s, v: 0 }));
62+
63+
const pipeline = [
64+
{ $match: match },
65+
{
66+
$facet: {
67+
statusCounts: [
68+
{ $group: { _id: { $ifNull: ['$status', 'unknown'] }, count: { $sum: 1 } } },
69+
{ $group: { _id: null, counts: { $push: { k: '$_id', v: '$count' } } } },
70+
{
71+
$project: {
72+
statusCounts: {
73+
$arrayToObject: {
74+
$concatArrays: [{ $literal: defaultCounts }, '$counts']
75+
}
76+
}
77+
}
78+
},
79+
{ $replaceRoot: { newRoot: '$statusCounts' } }
80+
],
81+
tasksByName: [
82+
{
83+
$group: {
84+
_id: '$name',
85+
totalCount: { $sum: 1 },
86+
lastRun: { $max: '$scheduledAt' },
87+
pending: { $sum: { $cond: [{ $eq: ['$status', 'pending'] }, 1, 0] } },
88+
succeeded: { $sum: { $cond: [{ $eq: ['$status', 'succeeded'] }, 1, 0] } },
89+
failed: { $sum: { $cond: [{ $eq: ['$status', 'failed'] }, 1, 0] } },
90+
cancelled: { $sum: { $cond: [{ $eq: ['$status', 'cancelled'] }, 1, 0] } }
91+
}
92+
},
93+
{
94+
$project: {
95+
_id: 0,
96+
name: '$_id',
97+
totalCount: 1,
98+
lastRun: 1,
99+
statusCounts: {
100+
pending: '$pending',
101+
succeeded: '$succeeded',
102+
failed: '$failed',
103+
cancelled: '$cancelled'
104+
}
105+
}
106+
},
107+
{ $sort: { name: 1 } }
108+
]
109+
}
110+
}
111+
];
112+
113+
const [result] = await Task.aggregate(pipeline);
114+
115+
return {
116+
statusCounts: result.statusCounts?.[0] ?? {},
117+
tasksByName: result.tasksByName || []
118+
};
119+
};

backend/actions/Task/getTasks.js

Lines changed: 81 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3,63 +3,99 @@
33
const Archetype = require('archetype');
44

55
const GetTasksParams = new Archetype({
6-
start: {
7-
$type: Date,
8-
$required: true
9-
},
10-
end: {
11-
$type: Date
12-
},
13-
status: {
14-
$type: 'string'
15-
},
16-
name: {
17-
$type: 'string'
18-
}
6+
start: { $type: Date },
7+
end: { $type: Date },
8+
status: { $type: 'string' },
9+
name: { $type: 'string' },
10+
skip: { $type: Number, $default: 0 },
11+
limit: { $type: Number, $default: 100 }
1912
}).compile('GetTasksParams');
2013

21-
module.exports = ({ db }) => async function getTasks(params) {
22-
params = new GetTasksParams(params);
23-
const { start, end, status, name } = params;
24-
const { Task } = db.models;
14+
const ALL_STATUSES = ['pending', 'in_progress', 'succeeded', 'failed', 'cancelled', 'unknown'];
15+
16+
/** Max documents per request to avoid excessive memory and response size. */
17+
const MAX_LIMIT = 2000;
2518

26-
const filter = {};
19+
/** Escape special regex characters so the name is matched literally. */
20+
function escapeRegex(str) {
21+
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
22+
}
2723

28-
if (start && end) {
29-
filter.scheduledAt = { $gte: start, $lt: end };
30-
} else if (start) {
31-
filter.scheduledAt = { $gte: start };
24+
function buildMatch(params) {
25+
const { start, end, status, name } = params;
26+
const match = {};
27+
if (start != null && end != null) {
28+
match.scheduledAt = { $gte: start, $lt: end };
29+
} else if (start != null) {
30+
match.scheduledAt = { $gte: start };
3231
}
33-
if (status) {
34-
filter.status = status;
32+
const statusVal = typeof status === 'string' ? status.trim() : status;
33+
if (statusVal != null && statusVal !== '') {
34+
match.status = statusVal;
3535
} else {
36-
filter.status = { $in: ['pending', 'in_progress', 'succeeded', 'failed', 'cancelled', 'unknown'] };
36+
match.status = { $in: ALL_STATUSES };
3737
}
38-
if (name) {
39-
filter.name = { $regex: name, $options: 'i' };
38+
if (name != null && name !== '') {
39+
const nameStr = typeof name === 'string' ? name.trim() : String(name);
40+
match.name = { $regex: escapeRegex(nameStr), $options: 'i' };
4041
}
42+
return match;
43+
}
4144

42-
const tasks = await Task.find(filter);
45+
/** Projection done in aggregation: only fields needed by frontend, payload → parameters, _id → id. */
46+
const TASK_PROJECT_STAGE = {
47+
_id: 1,
48+
id: '$_id',
49+
name: 1,
50+
status: 1,
51+
scheduledAt: 1,
52+
createdAt: 1,
53+
startedAt: 1,
54+
completedAt: 1,
55+
error: 1,
56+
parameters: '$payload'
57+
};
4358

44-
// Define all possible statuses
45-
const allStatuses = ['pending', 'in_progress', 'succeeded', 'failed', 'cancelled', 'unknown'];
59+
function ensureDate(value) {
60+
if (value == null) return value;
61+
if (value instanceof Date) return value;
62+
if (typeof value === 'string' || typeof value === 'number') {
63+
const d = new Date(value);
64+
if (!Number.isNaN(d.getTime())) return d;
65+
}
66+
return value;
67+
}
4668

47-
// Initialize groupedTasks with all statuses
48-
const groupedTasks = allStatuses.reduce((groups, status) => {
49-
groups[status] = [];
50-
return groups;
51-
}, {});
69+
module.exports = ({ db }) => async function getTasks(params) {
70+
params = new GetTasksParams(params);
71+
params.start = ensureDate(params.start);
72+
params.end = ensureDate(params.end);
73+
if (typeof params.status === 'string') params.status = params.status.trim();
74+
if (typeof params.name === 'string') params.name = params.name.trim();
5275

53-
// Group tasks by status
54-
tasks.forEach(task => {
55-
const taskStatus = task.status || 'unknown';
56-
if (groupedTasks.hasOwnProperty(taskStatus)) {
57-
groupedTasks[taskStatus].push(task);
76+
const skip = Math.max(0, Number(params.skip) || 0);
77+
const limit = Math.min(MAX_LIMIT, Math.max(0, Number(params.limit) || 100));
78+
const { Task } = db.models;
79+
const match = buildMatch(params);
80+
81+
const pipeline = [
82+
{ $match: match },
83+
{
84+
$facet: {
85+
tasks: [
86+
{ $sort: { scheduledAt: -1 } },
87+
{ $skip: skip },
88+
{ $limit: limit },
89+
{ $project: TASK_PROJECT_STAGE }
90+
],
91+
count: [{ $count: 'total' }]
92+
}
5893
}
59-
});
94+
];
95+
96+
const [result] = await Task.aggregate(pipeline);
97+
const tasks = result.tasks || [];
98+
const numDocs = (result.count && result.count[0] && result.count[0].total) || 0;
6099

61-
return {
62-
tasks,
63-
groupedTasks
64-
};
100+
return { tasks, numDocs };
65101
};

backend/actions/Task/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@
33
exports.cancelTask = require('./cancelTask');
44
exports.createTask = require('./createTask');
55
exports.getTasks = require('./getTasks');
6+
exports.getTaskOverview = require('./getTaskOverview');
67
exports.rescheduleTask = require('./rescheduleTask');
78
exports.runTask = require('./runTask');

frontend/src/api.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,9 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
182182
getTasks: function getTasks(params) {
183183
return client.post('', { action: 'Task.getTasks', ...params }).then(res => res.data);
184184
},
185+
getTaskOverview: function getTaskOverview(params) {
186+
return client.post('', { action: 'Task.getTaskOverview', ...params }).then(res => res.data);
187+
},
185188
rescheduleTask: function rescheduleTask(params) {
186189
return client.post('', { action: 'Task.rescheduleTask', ...params }).then(res => res.data);
187190
},
@@ -513,6 +516,9 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
513516
getTasks: function getTasks(params) {
514517
return client.post('/Task/getTasks', params).then(res => res.data);
515518
},
519+
getTaskOverview: function getTaskOverview(params) {
520+
return client.post('/Task/getTaskOverview', params).then(res => res.data);
521+
},
516522
rescheduleTask: function rescheduleTask(params) {
517523
return client.post('/Task/rescheduleTask', params).then(res => res.data);
518524
},

frontend/src/task-by-name/task-by-name.html

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,81 @@
55
<div v-else-if="status === 'error'" class="text-red-600">
66
{{ errorMessage }}
77
</div>
8-
<task-details
9-
v-else-if="taskGroup"
10-
:task-group="taskGroup"
11-
:back-to="{ name: 'tasks' }"
12-
@task-created="onTaskCreated"
13-
@task-cancelled="onTaskCancelled"
14-
></task-details>
8+
<template v-else-if="taskGroup">
9+
<div class="pb-24">
10+
<router-link :to="{ name: 'tasks' }" class="inline-flex items-center gap-1 text-gray-500 hover:text-gray-700 mb-4">
11+
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
12+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
13+
</svg>
14+
Back to Task Groups
15+
</router-link>
16+
<div class="mb-4">
17+
<label class="block text-sm font-medium text-gray-700 mb-1">Filter by Date:</label>
18+
<select v-model="selectedRange" @change="updateDateRange" class="border-gray-300 rounded-md shadow-sm w-full p-2 max-w-xs">
19+
<option v-for="option in dateFilters" :key="option.value" :value="option.value">
20+
{{ option.label }}
21+
</option>
22+
</select>
23+
</div>
24+
<task-details
25+
:task-group="taskGroup"
26+
:back-to="{ name: 'tasks' }"
27+
:show-back-button="false"
28+
@task-created="onTaskCreated"
29+
@task-cancelled="onTaskCancelled"
30+
></task-details>
31+
</div>
32+
<div
33+
v-if="numDocs > 0"
34+
class="fixed bottom-0 left-0 right-0 z-10 px-4 py-4 bg-white border-t border-gray-200 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)]"
35+
>
36+
<div class="flex flex-wrap items-center justify-between gap-4 max-w-6xl mx-auto">
37+
<div class="flex items-center gap-6">
38+
<p class="text-sm text-gray-600">
39+
<span class="font-medium text-gray-900">{{ Math.min((page - 1) * pageSize + 1, numDocs) }}–{{ Math.min(page * pageSize, numDocs) }}</span>
40+
<span class="mx-1">of</span>
41+
<span class="font-medium text-gray-900">{{ numDocs }}</span>
42+
<span class="ml-1 text-gray-500">tasks</span>
43+
</p>
44+
<div class="flex items-center gap-2">
45+
<label class="text-sm font-medium text-gray-700">Per page</label>
46+
<select
47+
v-model="pageSize"
48+
@change="onPageSizeChange"
49+
class="border border-gray-300 rounded-md shadow-sm px-3 py-2 text-sm text-gray-700 bg-white hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-ultramarine-500 focus:border-ultramarine-500"
50+
>
51+
<option v-for="n in pageSizeOptions" :key="n" :value="n">{{ n }}</option>
52+
</select>
53+
</div>
54+
</div>
55+
<div class="flex items-center gap-1">
56+
<button
57+
type="button"
58+
:disabled="page <= 1"
59+
@click="goToPage(page - 1)"
60+
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md border transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white bg-white text-gray-700 border-gray-300 hover:bg-gray-50 hover:border-gray-400"
61+
>
62+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
63+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
64+
</svg>
65+
Previous
66+
</button>
67+
<span class="px-4 py-2 text-sm text-gray-600 min-w-[7rem] text-center">
68+
Page <span class="font-semibold text-gray-900">{{ page }}</span> of <span class="font-semibold text-gray-900">{{ Math.max(1, Math.ceil(numDocs / pageSize)) }}</span>
69+
</span>
70+
<button
71+
type="button"
72+
:disabled="page >= Math.ceil(numDocs / pageSize)"
73+
@click="goToPage(page + 1)"
74+
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md border transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white bg-white text-gray-700 border-gray-300 hover:bg-gray-50 hover:border-gray-400"
75+
>
76+
Next
77+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
78+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
79+
</svg>
80+
</button>
81+
</div>
82+
</div>
83+
</div>
84+
</template>
1585
</div>

0 commit comments

Comments
 (0)