Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions backend/actions/Task/getTaskOverview.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
'use strict';

const Archetype = require('archetype');
const escape = require('regexp.escape');

const GetTaskOverviewParams = new Archetype({
start: { $type: Date },
end: { $type: Date },
status: { $type: 'string' },
name: { $type: 'string' }
}).compile('GetTaskOverviewParams');

/** Statuses shown on the Task overview page. */
const OVERVIEW_STATUSES = ['pending', 'succeeded', 'failed', 'cancelled'];

function buildMatch(params) {
const { start, end, status, name } = params;
const match = {};
if (start != null && end != null) {
match.scheduledAt = { $gte: start, $lt: end };
} else if (start != null) {
match.scheduledAt = { $gte: start };
}
const statusVal = typeof status === 'string' ? status.trim() : status;
if (statusVal != null && statusVal !== '') {
match.status = statusVal;
} else {
match.status = { $in: ['pending', 'in_progress', 'succeeded', 'failed', 'cancelled', 'unknown'] };
}
if (name != null && name !== '') {
const nameStr = typeof name === 'string' ? name.trim() : String(name);
match.name = { $regex: escape(nameStr), $options: 'i' };
}
return match;
}

module.exports = ({ db }) => async function getTaskOverview(params) {
params = new GetTaskOverviewParams(params);
if (typeof params.status === 'string') params.status = params.status.trim();
if (typeof params.name === 'string') params.name = params.name.trim();
const { Task } = db.models;
const match = buildMatch(params);

const defaultCounts = OVERVIEW_STATUSES.map(s => ({ k: s, v: 0 }));

const pipeline = [
{ $match: match },
{
$facet: {
statusCounts: [
{ $group: { _id: { $ifNull: ['$status', 'unknown'] }, count: { $sum: 1 } } },
{ $group: { _id: null, counts: { $push: { k: '$_id', v: '$count' } } } },
{
$project: {
statusCounts: {
$arrayToObject: {
$concatArrays: [{ $literal: defaultCounts }, '$counts']
}
}
}
},
{ $replaceRoot: { newRoot: '$statusCounts' } }
],
tasksByName: [
{
$group: {
_id: '$name',
totalCount: { $sum: 1 },
lastRun: { $max: '$scheduledAt' },
pending: { $sum: { $cond: [{ $eq: ['$status', 'pending'] }, 1, 0] } },
succeeded: { $sum: { $cond: [{ $eq: ['$status', 'succeeded'] }, 1, 0] } },
failed: { $sum: { $cond: [{ $eq: ['$status', 'failed'] }, 1, 0] } },
cancelled: { $sum: { $cond: [{ $eq: ['$status', 'cancelled'] }, 1, 0] } }
}
},
{
$project: {
_id: 0,
name: '$_id',
totalCount: 1,
lastRun: 1,
statusCounts: {
pending: '$pending',
succeeded: '$succeeded',
failed: '$failed',
cancelled: '$cancelled'
}
}
Comment on lines +66 to +88
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tasksByName computes totalCount as $sum: 1 across all matched tasks, but the per-name statusCounts only counts 4 statuses (pending/succeeded/failed/cancelled). If any tasks are in_progress or unknown, totalCount will exceed the sum of the displayed counts (the same mismatch called out earlier for the UI). Consider either (a) adding in_progress/unknown (or an “other”) count fields in the $group + statusCounts projection, or (b) changing the default match to only include OVERVIEW_STATUSES when no explicit status filter is provided so totals and statusCounts stay consistent.

Copilot uses AI. Check for mistakes.
},
{ $sort: { name: 1 } }
]
}
}
];

const [result] = await Task.aggregate(pipeline);

return {
statusCounts: result.statusCounts?.[0] ?? {},
tasksByName: result.tasksByName || []
};
};
130 changes: 85 additions & 45 deletions backend/actions/Task/getTasks.js
Original file line number Diff line number Diff line change
@@ -1,65 +1,105 @@
'use strict';

const Archetype = require('archetype');
const escape = require('regexp.escape');

const GetTasksParams = new Archetype({
start: {
$type: Date,
$required: true
},
end: {
$type: Date
},
status: {
$type: 'string'
},
name: {
$type: 'string'
}
start: { $type: Date },
end: { $type: Date },
status: { $type: 'string' },
name: { $type: 'string' },
skip: { $type: 'number', $default: 0 },
limit: { $type: 'number', $default: 100 }
}).compile('GetTasksParams');

module.exports = ({ db }) => async function getTasks(params) {
params = new GetTasksParams(params);
const { start, end, status, name } = params;
const { Task } = db.models;
const ALL_STATUSES = ['pending', 'in_progress', 'succeeded', 'failed', 'cancelled', 'unknown'];

/** Status keys for statusCounts (same shape as getTaskOverview). */
const STATUS_COUNT_KEYS = ['pending', 'succeeded', 'failed', 'cancelled'];

const filter = {};
/** Max documents per request to avoid excessive memory and response size. */
const MAX_LIMIT = 2000;

if (start && end) {
filter.scheduledAt = { $gte: start, $lt: end };
} else if (start) {
filter.scheduledAt = { $gte: start };
function buildMatch(params) {
const { start, end, status, name } = params;
const match = {};
if (start != null && end != null) {
match.scheduledAt = { $gte: start, $lt: end };
} else if (start != null) {
match.scheduledAt = { $gte: start };
}
if (status) {
filter.status = status;
const statusVal = typeof status === 'string' ? status.trim() : status;
if (statusVal != null && statusVal !== '') {
match.status = statusVal;
} else {
filter.status = { $in: ['pending', 'in_progress', 'succeeded', 'failed', 'cancelled', 'unknown'] };
match.status = { $in: ALL_STATUSES };
}
if (name) {
filter.name = { $regex: name, $options: 'i' };
if (name != null && name !== '') {
const nameStr = typeof name === 'string' ? name.trim() : String(name);
match.name = { $regex: escape(nameStr), $options: 'i' };
}
return match;
}

const tasks = await Task.find(filter);
/** Projection done in aggregation: only fields needed by frontend, payload → parameters, _id → id. */
const TASK_PROJECT_STAGE = {
_id: 1,
id: '$_id',
name: 1,
status: 1,
scheduledAt: 1,
createdAt: 1,
startedAt: 1,
completedAt: 1,
error: 1,
parameters: '$payload'
};

module.exports = ({ db }) => async function getTasks(params) {
params = new GetTasksParams(params);
if (typeof params.status === 'string') params.status = params.status.trim();
if (typeof params.name === 'string') params.name = params.name.trim();

// Define all possible statuses
const allStatuses = ['pending', 'in_progress', 'succeeded', 'failed', 'cancelled', 'unknown'];
const skip = Math.max(0, Number(params.skip) || 0);
const limit = Math.min(MAX_LIMIT, Math.max(1, Number(params.limit) || 100));
const { Task } = db.models;
const match = buildMatch(params);

// Initialize groupedTasks with all statuses
const groupedTasks = allStatuses.reduce((groups, status) => {
groups[status] = [];
return groups;
}, {});
const defaultCounts = STATUS_COUNT_KEYS.map(s => ({ k: s, v: 0 }));

// Group tasks by status
tasks.forEach(task => {
const taskStatus = task.status || 'unknown';
if (groupedTasks.hasOwnProperty(taskStatus)) {
groupedTasks[taskStatus].push(task);
const pipeline = [
{ $match: match },
{
$facet: {
tasks: [
{ $sort: { scheduledAt: -1 } },
{ $skip: skip },
{ $limit: limit },
{ $project: TASK_PROJECT_STAGE }
],
count: [{ $count: 'total' }],
statusCounts: [
{ $group: { _id: { $ifNull: ['$status', 'unknown'] }, count: { $sum: 1 } } },
{ $group: { _id: null, counts: { $push: { k: '$_id', v: '$count' } } } },
{
$project: {
statusCounts: {
$arrayToObject: {
$concatArrays: [{ $literal: defaultCounts }, '$counts']
}
}
}
},
{ $replaceRoot: { newRoot: '$statusCounts' } }
]
}
}
});
];

const [result] = await Task.aggregate(pipeline);
const tasks = result.tasks || [];
const numDocs = (result.count && result.count[0] && result.count[0].total) || 0;
const statusCounts = result.statusCounts?.[0] ?? {};

return {
tasks,
groupedTasks
};
return { tasks, numDocs, statusCounts };
};
1 change: 1 addition & 0 deletions backend/actions/Task/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
exports.cancelTask = require('./cancelTask');
exports.createTask = require('./createTask');
exports.getTasks = require('./getTasks');
exports.getTaskOverview = require('./getTaskOverview');
exports.rescheduleTask = require('./rescheduleTask');
exports.runTask = require('./runTask');
82 changes: 82 additions & 0 deletions frontend/src/_util/dateRange.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
'use strict';

const DATE_FILTERS = [
{ value: 'last_hour', label: 'Last Hour' },
{ value: 'today', label: 'Today' },
{ value: 'yesterday', label: 'Yesterday' },
{ value: 'thisWeek', label: 'This Week' },
{ value: 'lastWeek', label: 'Last Week' },
{ value: 'thisMonth', label: 'This Month' },
{ value: 'lastMonth', label: 'Last Month' }
];

const DATE_FILTER_VALUES = DATE_FILTERS.map(f => f.value);

/**
* Returns { start, end } Date objects for a given range key (e.g. 'last_hour', 'today').
* Month ranges use UTC boundaries.
* @param {string} selectedRange - One of DATE_FILTER_VALUES
* @returns {{ start: Date, end: Date }}
*/
function getDateRangeForRange(selectedRange) {
const now = new Date();
let start, end;
switch (selectedRange) {
case 'last_hour':
start = new Date();
start.setHours(start.getHours() - 1);
end = new Date();
break;
case 'today':
start = new Date();
start.setHours(0, 0, 0, 0);
end = new Date();
end.setHours(23, 59, 59, 999);
break;
case 'yesterday':
start = new Date(now);
start.setDate(start.getDate() - 1);
start.setHours(0, 0, 0, 0);
end = new Date(start);
end.setHours(23, 59, 59, 999);
break;
case 'thisWeek':
start = new Date(now.getTime() - (7 * 86400000));
start.setHours(0, 0, 0, 0);
end = new Date();
end.setHours(23, 59, 59, 999);
break;
case 'lastWeek':
start = new Date(now.getTime() - (14 * 86400000));
start.setHours(0, 0, 0, 0);
end = new Date(now.getTime() - (7 * 86400000));
end.setHours(23, 59, 59, 999);
break;
case 'thisMonth': {
const y = now.getUTCFullYear();
const m = now.getUTCMonth();
start = new Date(Date.UTC(y, m, 1, 0, 0, 0, 0));
end = new Date(Date.UTC(y, m + 1, 0, 23, 59, 59, 999));
break;
}
case 'lastMonth': {
const y = now.getUTCFullYear();
const m = now.getUTCMonth();
start = new Date(Date.UTC(y, m - 1, 1, 0, 0, 0, 0));
end = new Date(Date.UTC(y, m, 0, 23, 59, 59, 999));
Comment on lines +33 to +66
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getDateRangeForRange() sets end to 23:59:59.999 for day/week ranges, but the backend queries use { scheduledAt: { $lt: end } }. That makes the last millisecond of the intended range exclusive, and mixes an inclusive-style end with an exclusive $lt query. To make the boundary unambiguous, consider returning an exclusive end (start of the next day / next week range) when the backend uses $lt, or switch the backend to $lte when using 23:59:59.999 style ends.

Suggested change
end = new Date();
end.setHours(23, 59, 59, 999);
break;
case 'yesterday':
start = new Date(now);
start.setDate(start.getDate() - 1);
start.setHours(0, 0, 0, 0);
end = new Date(start);
end.setHours(23, 59, 59, 999);
break;
case 'thisWeek':
start = new Date(now.getTime() - (7 * 86400000));
start.setHours(0, 0, 0, 0);
end = new Date();
end.setHours(23, 59, 59, 999);
break;
case 'lastWeek':
start = new Date(now.getTime() - (14 * 86400000));
start.setHours(0, 0, 0, 0);
end = new Date(now.getTime() - (7 * 86400000));
end.setHours(23, 59, 59, 999);
break;
case 'thisMonth': {
const y = now.getUTCFullYear();
const m = now.getUTCMonth();
start = new Date(Date.UTC(y, m, 1, 0, 0, 0, 0));
end = new Date(Date.UTC(y, m + 1, 0, 23, 59, 59, 999));
break;
}
case 'lastMonth': {
const y = now.getUTCFullYear();
const m = now.getUTCMonth();
start = new Date(Date.UTC(y, m - 1, 1, 0, 0, 0, 0));
end = new Date(Date.UTC(y, m, 0, 23, 59, 59, 999));
end = new Date(start);
end.setDate(end.getDate() + 1);
end.setHours(0, 0, 0, 0);
break;
case 'yesterday':
start = new Date(now);
start.setDate(start.getDate() - 1);
start.setHours(0, 0, 0, 0);
end = new Date(start);
end.setDate(end.getDate() + 1);
end.setHours(0, 0, 0, 0);
break;
case 'thisWeek':
start = new Date(now.getTime() - (7 * 86400000));
start.setHours(0, 0, 0, 0);
end = new Date();
end.setDate(end.getDate() + 1);
end.setHours(0, 0, 0, 0);
break;
case 'lastWeek':
start = new Date(now.getTime() - (14 * 86400000));
start.setHours(0, 0, 0, 0);
end = new Date(now.getTime() - (7 * 86400000));
end.setHours(0, 0, 0, 0);
break;
case 'thisMonth': {
const y = now.getUTCFullYear();
const m = now.getUTCMonth();
start = new Date(Date.UTC(y, m, 1, 0, 0, 0, 0));
end = new Date(Date.UTC(y, m + 1, 1, 0, 0, 0, 0));
break;
}
case 'lastMonth': {
const y = now.getUTCFullYear();
const m = now.getUTCMonth();
start = new Date(Date.UTC(y, m - 1, 1, 0, 0, 0, 0));
end = new Date(Date.UTC(y, m, 1, 0, 0, 0, 0));

Copilot uses AI. Check for mistakes.
break;
}
default:
start = new Date();
start.setHours(start.getHours() - 1);
end = new Date();
break;
}
return { start, end };
}

module.exports = {
DATE_FILTERS,
DATE_FILTER_VALUES,
getDateRangeForRange
};
6 changes: 6 additions & 0 deletions frontend/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,9 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
getTasks: function getTasks(params) {
return client.post('', { action: 'Task.getTasks', ...params }).then(res => res.data);
},
getTaskOverview: function getTaskOverview(params) {
return client.post('', { action: 'Task.getTaskOverview', ...params }).then(res => res.data);
},
rescheduleTask: function rescheduleTask(params) {
return client.post('', { action: 'Task.rescheduleTask', ...params }).then(res => res.data);
},
Expand Down Expand Up @@ -513,6 +516,9 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
getTasks: function getTasks(params) {
return client.post('/Task/getTasks', params).then(res => res.data);
},
getTaskOverview: function getTaskOverview(params) {
return client.post('/Task/getTaskOverview', params).then(res => res.data);
},
rescheduleTask: function rescheduleTask(params) {
return client.post('/Task/rescheduleTask', params).then(res => res.data);
},
Expand Down
Loading