Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
119 changes: 119 additions & 0 deletions backend/actions/Task/getTaskOverview.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
'use strict';

const Archetype = require('archetype');

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 ensureDate(value) {
if (value == null) return value;
if (value instanceof Date) return value;
if (typeof value === 'string' || typeof value === 'number') {
const d = new Date(value);
if (!Number.isNaN(d.getTime())) return d;
}
return value;
}

function escapeRegex(str) {
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function buildMatch(params) {
const { start, end, status, name } = params;
const match = {};
const startDate = ensureDate(start);
const endDate = ensureDate(end);
if (startDate != null && endDate != null) {
match.scheduledAt = { $gte: startDate, $lt: endDate };
} else if (startDate != null) {
match.scheduledAt = { $gte: startDate };
}
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: escapeRegex(nameStr), $options: 'i' };
}
return match;
}

module.exports = ({ db }) => async function getTaskOverview(params) {
params = new GetTaskOverviewParams(params);
params.start = ensureDate(params.start);
params.end = ensureDate(params.end);
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 || []
};
};
126 changes: 81 additions & 45 deletions backend/actions/Task/getTasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,63 +3,99 @@
const Archetype = require('archetype');

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'];

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

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

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: escapeRegex(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'
};

// Define all possible statuses
const allStatuses = ['pending', 'in_progress', 'succeeded', 'failed', 'cancelled', 'unknown'];
function ensureDate(value) {
if (value == null) return value;
if (value instanceof Date) return value;
if (typeof value === 'string' || typeof value === 'number') {
const d = new Date(value);
if (!Number.isNaN(d.getTime())) return d;
}
return value;
}

// Initialize groupedTasks with all statuses
const groupedTasks = allStatuses.reduce((groups, status) => {
groups[status] = [];
return groups;
}, {});
module.exports = ({ db }) => async function getTasks(params) {
params = new GetTasksParams(params);
params.start = ensureDate(params.start);
params.end = ensureDate(params.end);
if (typeof params.status === 'string') params.status = params.status.trim();
if (typeof params.name === 'string') params.name = params.name.trim();

// Group tasks by status
tasks.forEach(task => {
const taskStatus = task.status || 'unknown';
if (groupedTasks.hasOwnProperty(taskStatus)) {
groupedTasks[taskStatus].push(task);
const skip = Math.max(0, Number(params.skip) || 0);
const limit = Math.min(MAX_LIMIT, Math.max(0, Number(params.limit) || 100));
const { Task } = db.models;
const match = buildMatch(params);

const pipeline = [
{ $match: match },
{
$facet: {
tasks: [
{ $sort: { scheduledAt: -1 } },
{ $skip: skip },
{ $limit: limit },
{ $project: TASK_PROJECT_STAGE }
],
count: [{ $count: 'total' }]
}
}
});
];

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

return {
tasks,
groupedTasks
};
return { tasks, numDocs };
};
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');
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
84 changes: 77 additions & 7 deletions frontend/src/task-by-name/task-by-name.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,81 @@
<div v-else-if="status === 'error'" class="text-red-600">
{{ errorMessage }}
</div>
<task-details
v-else-if="taskGroup"
:task-group="taskGroup"
:back-to="{ name: 'tasks' }"
@task-created="onTaskCreated"
@task-cancelled="onTaskCancelled"
></task-details>
<template v-else-if="taskGroup">
<div class="pb-24">
<router-link :to="{ name: 'tasks' }" class="inline-flex items-center gap-1 text-gray-500 hover:text-gray-700 mb-4">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
Back to Task Groups
</router-link>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Filter by Date:</label>
<select v-model="selectedRange" @change="updateDateRange" class="border-gray-300 rounded-md shadow-sm w-full p-2 max-w-xs">
<option v-for="option in dateFilters" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<task-details
:task-group="taskGroup"
:back-to="{ name: 'tasks' }"
:show-back-button="false"
@task-created="onTaskCreated"
@task-cancelled="onTaskCancelled"
></task-details>
</div>
<div
v-if="numDocs > 0"
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)]"
>
<div class="flex flex-wrap items-center justify-between gap-4 max-w-6xl mx-auto">
<div class="flex items-center gap-6">
<p class="text-sm text-gray-600">
<span class="font-medium text-gray-900">{{ Math.min((page - 1) * pageSize + 1, numDocs) }}–{{ Math.min(page * pageSize, numDocs) }}</span>
<span class="mx-1">of</span>
<span class="font-medium text-gray-900">{{ numDocs }}</span>
<span class="ml-1 text-gray-500">tasks</span>
</p>
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-gray-700">Per page</label>
<select
v-model="pageSize"
@change="onPageSizeChange"
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"
>
<option v-for="n in pageSizeOptions" :key="n" :value="n">{{ n }}</option>
</select>
</div>
</div>
<div class="flex items-center gap-1">
<button
type="button"
:disabled="page <= 1"
@click="goToPage(page - 1)"
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"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
Previous
</button>
<span class="px-4 py-2 text-sm text-gray-600 min-w-[7rem] text-center">
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>
</span>
<button
type="button"
:disabled="page >= Math.ceil(numDocs / pageSize)"
@click="goToPage(page + 1)"
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"
>
Next
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</button>
</div>
</div>
</div>
</template>
</div>
Loading