diff --git a/backend/actions/Model/updateDocument.js b/backend/actions/Model/updateDocument.js index 5dbb3d4..4d4cf80 100644 --- a/backend/actions/Model/updateDocument.js +++ b/backend/actions/Model/updateDocument.js @@ -31,8 +31,8 @@ module.exports = ({ db }) => async function updateDocument(params) { throw new Error(`Model ${model} not found`); } - let setFields = {}; - let unsetFields = {}; + const setFields = {}; + const unsetFields = {}; if (Object.keys(update).length > 0) { Object.entries(update).forEach(([key, value]) => { diff --git a/backend/actions/Model/updateDocuments.js b/backend/actions/Model/updateDocuments.js index 07a8113..ee89463 100644 --- a/backend/actions/Model/updateDocuments.js +++ b/backend/actions/Model/updateDocuments.js @@ -31,8 +31,8 @@ module.exports = ({ db }) => async function updateDocuments(params) { throw new Error(`Model ${model} not found`); } - let setFields = {}; - let unsetFields = {}; + const setFields = {}; + const unsetFields = {}; if (Object.keys(update).length > 0) { Object.entries(update).forEach(([key, value]) => { diff --git a/backend/actions/Task/cancelTask.js b/backend/actions/Task/cancelTask.js new file mode 100644 index 0000000..3e00d44 --- /dev/null +++ b/backend/actions/Task/cancelTask.js @@ -0,0 +1,24 @@ +'use strict'; + +const Archetype = require('archetype'); +const mongoose = require('mongoose'); + +const CancelTaskParams = new Archetype({ + taskId: { + $type: mongoose.Types.ObjectId, + $required: true + } +}).compile('CancelTaskParams'); + +module.exports = ({ db }) => async function cancelTask(params) { + params = new CancelTaskParams(params); + const { taskId } = params; + const { Task } = db.models; + + const task = await Task.findOne({ _id: taskId }).orFail(); + + const cancelledTask = await Task.cancelTask({ _id: taskId }); + return { + task: cancelledTask + }; +}; \ No newline at end of file diff --git a/backend/actions/Task/createTask.js b/backend/actions/Task/createTask.js new file mode 100644 index 0000000..bdb5041 --- /dev/null +++ b/backend/actions/Task/createTask.js @@ -0,0 +1,33 @@ +'use strict'; + +const Archetype = require('archetype'); + +const CreateTaskParams = new Archetype({ + name: { + $type: 'string', + $required: true + }, + scheduledAt: { + $type: Date, + $required: true + }, + repeatAfterMS: { + $type: 'number' + }, + payload: { + $type: Archetype.Any + } +}).compile('CreateTaskParams'); + +module.exports = ({ db }) => async function createTask(params) { + params = new CreateTaskParams(params); + + const { name, scheduledAt, payload, repeatAfterMS } = params; + const { Task } = db.models; + + const task = await Task.schedule(name, scheduledAt, payload, repeatAfterMS); + + return { + task + }; +}; \ No newline at end of file diff --git a/backend/actions/Task/getTasks.js b/backend/actions/Task/getTasks.js new file mode 100644 index 0000000..23c1a2f --- /dev/null +++ b/backend/actions/Task/getTasks.js @@ -0,0 +1,62 @@ +'use strict'; + +const Archetype = require('archetype'); + +const GetTasksParams = new Archetype({ + start: { + $type: Date + }, + end: { + $type: Date + }, + status: { + $type: 'string' + }, + name: { + $type: 'string' + } +}).compile('GetTasksParams'); + +module.exports = ({ db }) => async function getTasks(params) { + params = new GetTasksParams(params); + const { start, end, status, name } = params; + const { Task } = db.models; + + const filter = {}; + + if (start && end) { + filter.scheduledAt = { $gte: start, $lt: end }; + } else if (start) { + filter.scheduledAt = { $gte: start }; + } + if (status) { + filter.status = status; + } + if (name) { + filter.name = { $regex: name, $options: 'i' }; + } + + const tasks = await Task.find(filter); + + // Define all possible statuses + const allStatuses = ['pending', 'in_progress', 'succeeded', 'failed', 'cancelled', 'unknown']; + + // Initialize groupedTasks with all statuses + const groupedTasks = allStatuses.reduce((groups, status) => { + groups[status] = []; + return groups; + }, {}); + + // Group tasks by status + tasks.forEach(task => { + const taskStatus = task.status || 'unknown'; + if (groupedTasks.hasOwnProperty(taskStatus)) { + groupedTasks[taskStatus].push(task); + } + }); + + return { + tasks, + groupedTasks + }; +}; \ No newline at end of file diff --git a/backend/actions/Task/index.js b/backend/actions/Task/index.js new file mode 100644 index 0000000..4ed726e --- /dev/null +++ b/backend/actions/Task/index.js @@ -0,0 +1,7 @@ +'use strict'; + +exports.cancelTask = require('./cancelTask'); +exports.createTask = require('./createTask'); +exports.getTasks = require('./getTasks'); +exports.rescheduleTask = require('./rescheduleTask'); +exports.runTask = require('./runTask'); \ No newline at end of file diff --git a/backend/actions/Task/rescheduleTask.js b/backend/actions/Task/rescheduleTask.js new file mode 100644 index 0000000..8c7260e --- /dev/null +++ b/backend/actions/Task/rescheduleTask.js @@ -0,0 +1,39 @@ +'use strict'; + +const Archetype = require('archetype'); +const mongoose = require('mongoose'); + +const RescheduleTaskParams = new Archetype({ + taskId: { + $type: mongoose.Types.ObjectId, + $required: true + }, + scheduledAt: { + $type: Date, + $required: true + } +}).compile('RescheduleTaskParams'); + +module.exports = ({ db }) => async function rescheduleTask(params) { + params = new RescheduleTaskParams(params); + const { taskId, scheduledAt } = params; + const { Task } = db.models; + + const task = await Task.findOne({ _id: taskId }).orFail(); + + if (scheduledAt < Date.now()) { + throw new Error('Cannot reschedule a task for the past'); + } + + if (task.status != 'pending') { + throw new Error('Cannot reschedule a task that is not pending'); + } + + task.scheduledAt = scheduledAt; + + await task.save(); + + return { + task + }; +}; \ No newline at end of file diff --git a/backend/actions/Task/runTask.js b/backend/actions/Task/runTask.js new file mode 100644 index 0000000..e4df320 --- /dev/null +++ b/backend/actions/Task/runTask.js @@ -0,0 +1,25 @@ +'use strict'; + +const Archetype = require('archetype'); +const mongoose = require('mongoose'); + +const RunTaskParams = new Archetype({ + taskId: { + $type: mongoose.Types.ObjectId, + $required: true + } +}).compile('RunTaskParams'); + +module.exports = ({ db }) => async function runTask(params) { + params = new RunTaskParams(params); + const { taskId } = params; + const { Task } = db.models; + + const task = await Task.findOne({ _id: taskId }).orFail(); + + const executedTask = await Task.execute(task); + + return { + task: executedTask + }; +}; \ No newline at end of file diff --git a/backend/actions/index.js b/backend/actions/index.js index b5eb067..eab08bd 100644 --- a/backend/actions/index.js +++ b/backend/actions/index.js @@ -6,3 +6,4 @@ exports.Dashboard = require('./Dashboard'); exports.Model = require('./Model'); exports.Script = require('./Script'); exports.status = require('./status'); +exports.Task = require('./Task'); diff --git a/eslint.config.js b/eslint.config.js index a77972c..915a4df 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -36,6 +36,7 @@ module.exports = defineConfig([ fetch: true, __dirname: true, process: true, + clearTimeout: true, setTimeout: true, navigator: true, TextDecoder: true diff --git a/express.js b/express.js index e69141b..fbd1539 100644 --- a/express.js +++ b/express.js @@ -79,6 +79,7 @@ module.exports = async function(apiUrl, conn, options) { console.log('Workspace', workspace); const { config } = await frontend(apiUrl, false, options, workspace); + config.enableTaskVisualizer = options.enableTaskVisualizer; router.get('/config.js', function (req, res) { res.setHeader('Content-Type', 'application/javascript'); res.end(`window.MONGOOSE_STUDIO_CONFIG = ${JSON.stringify(config, null, 2)};`); diff --git a/frontend/src/api.js b/frontend/src/api.js index de9e19e..76b2197 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -141,6 +141,23 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) { return client.post('', { action: 'Model.updateDocuments', ...params }).then(res => res.data); } }; + exports.Task = { + cancelTask: function cancelTask(params) { + return client.post('', { action: 'Task.cancelTask', ...params }).then(res => res.data); + }, + createTask: function createTask(params) { + return client.post('', { action: 'Task.createTask', ...params }).then(res => res.data); + }, + getTasks: function getTasks(params) { + return client.post('', { action: 'Task.getTasks', ...params }).then(res => res.data); + }, + rescheduleTask: function rescheduleTask(params) { + return client.post('', { action: 'Task.rescheduleTask', ...params }).then(res => res.data); + }, + runTask: function runTask(params) { + return client.post('', { action: 'Task.runTask', ...params }).then(res => res.data); + } + }; } else { exports.status = function status() { return client.get('/status').then(res => res.data); @@ -298,4 +315,21 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) { return client.post('/Model/updateDocuments', params).then(res => res.data); } }; + exports.Task = { + cancelTask: function cancelTask(params) { + return client.post('/Task/cancelTask', params).then(res => res.data); + }, + createTask: function createTask(params) { + return client.post('/Task/createTask', params).then(res => res.data); + }, + getTasks: function getTasks(params) { + return client.post('/Task/getTasks', params).then(res => res.data); + }, + rescheduleTask: function rescheduleTask(params) { + return client.post('/Task/rescheduleTask', params).then(res => res.data); + }, + runTask: function runTask(params) { + return client.post('/Task/runTask', params).then(res => res.data); + } + }; } diff --git a/frontend/src/document-details/document-details.js b/frontend/src/document-details/document-details.js index 1f96c95..9ffcf69 100644 --- a/frontend/src/document-details/document-details.js +++ b/frontend/src/document-details/document-details.js @@ -320,9 +320,9 @@ module.exports = app => app.component('document-details', { toSnakeCase(str) { return str .trim() - .replace(/\s+/g, '_') // Replace spaces with underscores - .replace(/[^a-zA-Z0-9_$]/g, '') // Remove invalid characters - .replace(/^[0-9]/, '_$&') // Prefix numbers with underscore + .replace(/\s+/g, '_') // Replace spaces with underscores + .replace(/[^a-zA-Z0-9_$]/g, '') // Remove invalid characters + .replace(/^[0-9]/, '_$&') // Prefix numbers with underscore .toLowerCase(); }, getTransformedFieldName() { diff --git a/frontend/src/index.js b/frontend/src/index.js index 3263208..03b41e9 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -10,7 +10,7 @@ console.log(`Mongoose Studio Version ${version}`); const api = require('./api'); const format = require('./format'); const mothership = require('./mothership'); -const { routes } = require('./routes'); +const { routes, hasAccess } = require('./routes'); const vanillatoasts = require('vanillatoasts'); const app = Vue.createApp({ @@ -141,6 +141,41 @@ const router = VueRouter.createRouter({ })) }); +// Add global navigation guard +router.beforeEach((to, from, next) => { + // Skip auth check for authorized (public) routes + if (to.meta.authorized) { + next(); + return; + } + + // Get roles from the app state + const roles = window.state?.roles; + + // Check if user has access to the route + if (!hasAccess(roles, to.name)) { + // Find all routes the user has access to + const allowedRoutes = routes.filter(route => hasAccess(roles, route.name)); + + // If user has no allowed routes, redirect to splash/login + if (allowedRoutes.length === 0) { + next({ name: 'root' }); + return; + } + + // Redirect to first allowed route + const firstAllowedRoute = allowedRoutes[0].name; + next({ name: firstAllowedRoute }); + return; + } + + if (to.name === 'root' && roles && roles[0] === 'dashboards') { + return next({ name: 'dashboards' }); + } + + next(); +}); + router.beforeEach((to, from, next) => { if (to.name === 'root' && window.state.roles && window.state.roles[0] === 'dashboards') { return next({ name: 'dashboards' }); diff --git a/frontend/src/models/models.css b/frontend/src/models/models.css index 836d706..92f8e18 100644 --- a/frontend/src/models/models.css +++ b/frontend/src/models/models.css @@ -92,7 +92,7 @@ td { .models .documents-menu { position: fixed; background-color: white; - z-index: 1; + z-index: 10; padding: 4px; display: flex; width: 100vw; diff --git a/frontend/src/navbar/navbar.html b/frontend/src/navbar/navbar.html index 57aceab..3299615 100644 --- a/frontend/src/navbar/navbar.html +++ b/frontend/src/navbar/navbar.html @@ -23,7 +23,7 @@ href="#/dashboards" class="inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium" :class="dashboardView ? 'text-gray-900 border-ultramarine-500' : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'">Dashboards - + Dashboards + Tasks
+

{{ taskGroup.name }}

+

Total: {{ taskGroup.totalCount }} tasks

+
+ + + + +
+ + + + +
+ + +
+
+

+ Individual Tasks + + (Filtered by {{ currentFilter }}) + +

+ +
+
+
+
+
+
+ Task ID: {{ task.id }} + + {{ task.status }} + +
+ +
+
+ +
{{ formatDate(task.scheduledAt) }}
+
+
+ +
{{ formatDate(task.startedAt) }}
+
+
+ +
{{ formatDate(task.completedAt) }}
+
+
+ +
{{ task.error }}
+
+
+ + +
+ +
+
{{ JSON.stringify(task.parameters, null, 2) }}
+
+
+
+ +
+ + + +
+
+
+
+
+ + + + + + + + + + + + + + + + + diff --git a/frontend/src/tasks/task-details/task-details.js b/frontend/src/tasks/task-details/task-details.js new file mode 100644 index 0000000..5dbfc5b --- /dev/null +++ b/frontend/src/tasks/task-details/task-details.js @@ -0,0 +1,183 @@ +'use strict'; + +const template = require('./task-details.html'); +const api = require('../../api'); +const vanillatoasts = require('vanillatoasts'); + +module.exports = app => app.component('task-details', { + props: ['taskGroup', 'currentFilter'], + data: () => ({ + showRescheduleModal: false, + showRunModal: false, + showCancelModal: false, + selectedTask: null, + newScheduledTime: '' + }), + computed: { + sortedTasks() { + let tasks = this.taskGroup.tasks; + + // Apply filter if one is set + if (this.currentFilter) { + tasks = tasks.filter(task => task.status === this.currentFilter); + } + + return tasks.sort((a, b) => { + const dateA = new Date(a.scheduledAt || a.createdAt || 0); + const dateB = new Date(b.scheduledAt || b.createdAt || 0); + return dateB - dateA; // Most recent first + }); + } + }, + methods: { + getStatusColor(status) { + if (status === 'succeeded') { + return 'bg-green-100 text-green-800'; + } else if (status === 'pending') { + return 'bg-yellow-100 text-yellow-800'; + } else if (status === 'cancelled') { + return 'bg-gray-100 text-gray-800'; + } else if (status === 'failed') { + return 'bg-red-100 text-red-800'; + } else if (status === 'in_progress') { + return 'bg-blue-100 text-blue-800'; + } else { + return 'bg-slate-100 text-slate-800'; + } + }, + formatDate(dateString) { + if (!dateString) return 'N/A'; + return new Date(dateString).toLocaleString(); + }, + async rescheduleTask(task) { + if (!this.newScheduledTime) { + return; + } + console.log('Rescheduling task:', task.id, 'to:', this.newScheduledTime); + await api.Task.rescheduleTask({ taskId: task.id, scheduledAt: this.newScheduledTime }); + }, + async runTask(task) { + console.log('Running task:', task.id); + await api.Task.runTask({ taskId: task.id }); + }, + async cancelTask(task) { + await api.Task.cancelTask({ taskId: task.id }); + // Refresh the task data by emitting an event to the parent + this.$emit('task-cancelled'); + }, + filterByStatus(status) { + // If clicking the same status, clear the filter + if (this.currentFilter === status) { + this.$emit('update:currentFilter', null); + } else { + this.$emit('update:currentFilter', status); + } + }, + clearFilter() { + this.$emit('update:currentFilter', null); + }, + showRescheduleConfirmation(task) { + this.selectedTask = task; + // Set default time to 1 hour from now + const defaultTime = new Date(); + defaultTime.setHours(defaultTime.getHours() + 1); + this.newScheduledTime = defaultTime.toISOString().slice(0, 16); + this.showRescheduleModal = true; + }, + showRunConfirmation(task) { + this.selectedTask = task; + this.showRunModal = true; + }, + showCancelConfirmation(task) { + this.selectedTask = task; + this.showCancelModal = true; + }, + async confirmRescheduleTask() { + try { + await this.rescheduleTask(this.selectedTask); + + // Show success message + vanillatoasts.create({ + title: 'Task Rescheduled Successfully!', + text: `Task ${this.selectedTask.id} has been rescheduled`, + type: 'success', + timeout: 3000, + positionClass: 'bottomRight' + }); + + this.showRescheduleModal = false; + this.selectedTask = null; + this.newScheduledTime = ''; + } catch (error) { + console.error('Error in confirmRescheduleTask:', error); + vanillatoasts.create({ + title: 'Failed to Reschedule Task', + text: error?.response?.data?.message || error.message || 'An unexpected error occurred', + type: 'error', + timeout: 5000, + positionClass: 'bottomRight' + }); + } + }, + async confirmRunTask() { + try { + await this.runTask(this.selectedTask); + + // Show success message + vanillatoasts.create({ + title: 'Task Started Successfully!', + text: `Task ${this.selectedTask.id} is now running`, + type: 'success', + timeout: 3000, + positionClass: 'bottomRight' + }); + + this.showRunModal = false; + this.selectedTask = null; + } catch (error) { + console.error('Error in confirmRunTask:', error); + vanillatoasts.create({ + title: 'Failed to Run Task', + text: error?.response?.data?.message || error.message || 'An unexpected error occurred', + type: 'error', + timeout: 5000, + positionClass: 'bottomRight' + }); + } + }, + async confirmCancelTask() { + try { + await this.cancelTask(this.selectedTask); + + // Show success message + vanillatoasts.create({ + title: 'Task Cancelled Successfully!', + text: `Task ${this.selectedTask.id} has been cancelled`, + type: 'success', + timeout: 3000, + positionClass: 'bottomRight' + }); + + this.showCancelModal = false; + this.selectedTask = null; + } catch (error) { + console.error('Error in confirmCancelTask:', error); + vanillatoasts.create({ + title: 'Failed to Cancel Task', + text: error?.response?.data?.message || error.message || 'An unexpected error occurred', + type: 'error', + timeout: 5000, + positionClass: 'bottomRight' + }); + } + } + + }, + mounted() { + // Check if the task group was already filtered when passed from parent + if (this.taskGroup.filteredStatus && !this.currentFilter) { + this.$emit('update:currentFilter', this.taskGroup.filteredStatus); + } + }, + template: template +}); diff --git a/frontend/src/tasks/tasks.css b/frontend/src/tasks/tasks.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/tasks/tasks.html b/frontend/src/tasks/tasks.html new file mode 100644 index 0000000..e17a7c3 --- /dev/null +++ b/frontend/src/tasks/tasks.html @@ -0,0 +1,220 @@ +
+ + + + +
+

Task Overview

+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ +
+ + + + +
+ + +
+

Tasks by Name

+
    +
  • +
    +
    +
    +
    {{ group.name }}
    + + + +
    +
    Total: {{ group.totalCount }} tasks
    +
    + Click to view details +
    +
    +
    + Last run: {{ group.lastRun ? new Date(group.lastRun).toLocaleString() : 'Never' }} +
    +
    + + +
    + + + + +
    +
  • +
+
+
+
+ + + + + +
diff --git a/frontend/src/tasks/tasks.js b/frontend/src/tasks/tasks.js new file mode 100644 index 0000000..eadc1fb --- /dev/null +++ b/frontend/src/tasks/tasks.js @@ -0,0 +1,374 @@ +'use strict'; + +const template = require('./tasks.html'); +const api = require('../api'); +const vanillatoasts = require('vanillatoasts'); + + +module.exports = app => app.component('tasks', { + data: () => ({ + status: 'init', + tasks: [], + groupedTasks: {}, + selectedRange: 'today', + start: null, + end: null, + dateFilters: [ + { value: 'all', label: 'All Time' }, + { 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' } + ], + selectedStatus: 'all', + statusFilters: [ + { label: 'All', value: 'all' }, + { label: 'Pending', value: 'pending' }, + // { label: 'In Progress', value: 'in_progress' }, + { label: 'Succeeded', value: 'succeeded' }, + { label: 'Failed', value: 'failed' }, + { label: 'Cancelled', value: 'cancelled' } + ], + searchQuery: '', + searchTimeout: null, + // Task details view state + showTaskDetails: false, + selectedTaskGroup: null, + taskDetailsFilter: null, + // Create task modal state + showCreateTaskModal: false, + newTask: { + name: '', + scheduledAt: '', + parameters: '', + repeatInterval: '' + }, + parametersEditor: null + }), + methods: { + async getTasks() { + const params = {}; + if (this.selectedStatus == 'all') { + params.status = null; + } else { + params.status = this.selectedStatus; + } + + if (this.start && this.end) { + params.start = this.start; + params.end = this.end; + } else if (this.start) { + params.start = this.start; + } + + if (this.searchQuery.trim()) { + params.name = this.searchQuery.trim(); + } + + const { tasks, groupedTasks } = await api.Task.getTasks(params); + this.tasks = tasks; + this.groupedTasks = groupedTasks; + }, + openTaskGroupDetails(group) { + this.selectedTaskGroup = group; + this.showTaskDetails = true; + }, + openTaskGroupDetailsWithFilter(group, status) { + // Create a filtered version of the task group with only the specified status + const filteredGroup = { + ...group, + tasks: group.tasks.filter(task => task.status === status), + filteredStatus: status + }; + this.selectedTaskGroup = filteredGroup; + this.taskDetailsFilter = status; + this.showTaskDetails = true; + }, + async onTaskCancelled() { + // Refresh the task data when a task is cancelled + await this.getTasks(); + }, + hideTaskDetails() { + this.showTaskDetails = false; + this.selectedTaskGroup = null; + this.taskDetailsFilter = null; + }, + async onTaskCreated() { + // Refresh the task data when a new task is created + await this.getTasks(); + }, + formatDate(dateString) { + if (!dateString) return 'N/A'; + return new Date(dateString).toLocaleString(); + }, + async createTask() { + try { + let parameters = {}; + const parametersText = this.parametersEditor ? this.parametersEditor.getValue() : ''; + if (parametersText.trim()) { + try { + parameters = JSON.parse(parametersText); + } catch (e) { + console.error('Invalid JSON in parameters field:', e); + vanillatoasts.create({ + title: 'Invalid JSON Parameters', + text: 'Please check your JSON syntax in the parameters field', + type: 'error', + timeout: 5000, + positionClass: 'bottomRight' + }); + return; + } + } + + // Validate repeat interval + let repeatInterval = null; + if (this.newTask.repeatInterval && this.newTask.repeatInterval.trim()) { + const interval = parseInt(this.newTask.repeatInterval); + if (isNaN(interval) || interval < 0) { + console.error('Invalid repeat interval. Must be a positive number.'); + vanillatoasts.create({ + title: 'Invalid Repeat Interval', + text: 'Repeat interval must be a positive number (in milliseconds)', + type: 'error', + timeout: 5000, + positionClass: 'bottomRight' + }); + return; + } + repeatInterval = interval; + } + + const taskData = { + name: this.newTask.name, + scheduledAt: this.newTask.scheduledAt, + payload: parameters, + repeatAfterMS: repeatInterval + }; + + console.log('Creating task:', taskData); + await api.Task.createTask(taskData); + + // Show success message + vanillatoasts.create({ + title: 'Task Created Successfully!', + text: `Task "${taskData.name}" has been scheduled`, + type: 'success', + timeout: 3000, + positionClass: 'bottomRight' + }); + + // Close modal (which will reset form) + this.closeCreateTaskModal(); + + // Refresh the task data + await this.getTasks(); + } catch (error) { + console.error('Error creating task:', error); + vanillatoasts.create({ + title: 'Failed to Create Task', + text: error?.response?.data?.message || error.message || 'An unexpected error occurred', + type: 'error', + timeout: 5000, + positionClass: 'bottomRight' + }); + } + }, + resetCreateTaskForm() { + this.newTask = { + name: '', + scheduledAt: '', + parameters: '', + repeatInterval: '' + }; + if (this.parametersEditor) { + this.parametersEditor.setValue(''); + } + }, + setDefaultCreateTaskValues() { + // Set default scheduled time to 1 hour from now + const defaultTime = new Date(); + defaultTime.setHours(defaultTime.getHours() + 1); + this.newTask.scheduledAt = defaultTime.toISOString().slice(0, 16); + }, + closeCreateTaskModal() { + this.showCreateTaskModal = false; + this.resetCreateTaskForm(); + this.setDefaultCreateTaskValues(); + }, + initializeParametersEditor() { + if (this.$refs.parametersEditor && !this.parametersEditor) { + this.parametersEditor = CodeMirror.fromTextArea(this.$refs.parametersEditor, { + mode: 'javascript', + lineNumbers: true, + smartIndent: false, + theme: 'default' + }); + } + }, + openCreateTaskModal() { + this.showCreateTaskModal = true; + this.$nextTick(() => { + this.initializeParametersEditor(); + }); + }, + getStatusColor(status) { + if (status === 'succeeded') { + // Green (success) + return 'bg-green-100 text-green-800'; + } else if (status === 'pending') { + // Yellow (waiting) + return 'bg-yellow-100 text-yellow-800'; + } else if (status === 'cancelled') { + // Gray (neutral/aborted) + return 'bg-gray-100 text-gray-800'; + } else if (status === 'failed') { + // Red (error) + return 'bg-red-100 text-red-800'; + } else if (status === 'in_progress') { + // Blue (active/running) + return 'bg-blue-100 text-blue-800'; + } else { + // Default (fallback) + return 'bg-slate-100 text-slate-800'; + } + }, + async resetFilters() { + this.selectedStatus = 'all'; + this.selectedRange = 'today'; + this.searchQuery = ''; + await this.updateDateRange(); + }, + async setStatusFilter(status) { + this.selectedStatus = status; + await this.getTasks(); + }, + async onSearchInput() { + // Debounce the search to avoid too many API calls + clearTimeout(this.searchTimeout); + this.searchTimeout = setTimeout(async() => { + await this.getTasks(); + }, 300); + }, + async updateDateRange() { + const now = new Date(); + let start, end; + + switch (this.selectedRange) { + 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(); + start.setDate(start.getDate() - 1); + start.setHours(0, 0, 0, 0); + end = new Date(); + 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': + start = new Date(now.getFullYear(), now.getMonth(), 1); + end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999); + break; + case 'lastMonth': + start = new Date(now.getFullYear(), now.getMonth() - 1, 1); + end = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999); + break; + case 'all': + default: + this.start = null; + this.end = null; + break; + } + + this.start = start; + this.end = end; + + await this.getTasks(); + } + }, + computed: { + tasksByName() { + const groups = {}; + + // Process tasks from groupedTasks to create name-based groups + Object.entries(this.groupedTasks).forEach(([status, tasks]) => { + tasks.forEach(task => { + if (!groups[task.name]) { + groups[task.name] = { + name: task.name, + tasks: [], + statusCounts: { + pending: 0, + succeeded: 0, + failed: 0, + cancelled: 0 + }, + totalCount: 0, + lastRun: null + }; + } + + groups[task.name].tasks.push(task); + groups[task.name].totalCount++; + + // Count status using the status from groupedTasks + if (groups[task.name].statusCounts.hasOwnProperty(status)) { + groups[task.name].statusCounts[status]++; + } + + // Track last run time + const taskTime = new Date(task.scheduledAt || task.createdAt || 0); + if (!groups[task.name].lastRun || taskTime > new Date(groups[task.name].lastRun)) { + groups[task.name].lastRun = taskTime; + } + }); + }); + + // Convert to array and sort alphabetically by name + return Object.values(groups).sort((a, b) => { + return a.name.localeCompare(b.name); + }); + }, + succeededCount() { + return this.groupedTasks.succeeded ? this.groupedTasks.succeeded.length : 0; + }, + failedCount() { + return this.groupedTasks.failed ? this.groupedTasks.failed.length : 0; + }, + cancelledCount() { + return this.groupedTasks.cancelled ? this.groupedTasks.cancelled.length : 0; + }, + pendingCount() { + return this.groupedTasks.pending ? this.groupedTasks.pending.length : 0; + } + }, + mounted: async function() { + await this.updateDateRange(); + await this.getTasks(); + this.status = 'loaded'; + this.setDefaultCreateTaskValues(); + }, + beforeDestroy() { + if (this.parametersEditor) { + this.parametersEditor.toTextArea(); + } + }, + + template: template +}); \ No newline at end of file diff --git a/package.json b/package.json index 49b2cef..d0ac7a3 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ }, "devDependencies": { "@masteringjs/eslint-config": "0.1.1", + "@mongoosejs/task": "^0.3.0", "axios": "1.2.2", "dedent": "^1.6.0", "eslint": "9.30.0",