agendash
Version:
Dashboard for Agenda job scheduler
267 lines • 9.1 kB
JavaScript
/**
* Agendash Controller - Core logic for dashboard operations
*
* Uses the database-agnostic Agenda API (v6) for all operations:
* - agenda.queryJobs() for filtering, pagination, state computation
* - agenda.getJobsOverview() for statistics
* - agenda.cancel() for deletion
*/
export class AgendashController {
agenda;
constructor(agenda) {
this.agenda = agenda;
}
/**
* Transform a job from the new API format to the frontend format
*/
transformJob(job) {
return {
job: {
_id: String(job._id),
name: job.name,
data: job.data,
priority: job.priority,
nextRunAt: job.nextRunAt,
lastRunAt: job.lastRunAt,
lastFinishedAt: job.lastFinishedAt,
lockedAt: job.lockedAt,
failedAt: job.failedAt,
failCount: job.failCount,
failReason: job.failReason,
repeatInterval: job.repeatInterval,
repeatTimezone: job.repeatTimezone,
disabled: job.disabled,
progress: job.progress
},
running: job.state === 'running',
scheduled: job.state === 'scheduled',
queued: job.state === 'queued',
completed: job.state === 'completed',
failed: job.state === 'failed',
repeating: job.state === 'repeating',
paused: job.disabled === true
};
}
/**
* Transform overview from new API format to frontend format
*/
transformOverview(overviews) {
// Calculate totals for "All Jobs" entry
const totals = overviews.reduce((acc, o) => ({
total: acc.total + o.total,
running: acc.running + o.running,
scheduled: acc.scheduled + o.scheduled,
queued: acc.queued + o.queued,
completed: acc.completed + o.completed,
failed: acc.failed + o.failed,
repeating: acc.repeating + o.repeating,
paused: acc.paused + o.paused
}), { total: 0, running: 0, scheduled: 0, queued: 0, completed: 0, failed: 0, repeating: 0, paused: 0 });
const allJobsEntry = {
displayName: 'All Jobs',
...totals
};
const jobOverviews = overviews.map((o) => ({
displayName: o.name,
total: o.total,
running: o.running,
scheduled: o.scheduled,
queued: o.queued,
completed: o.completed,
failed: o.failed,
repeating: o.repeating,
paused: o.paused
}));
return [allJobsEntry, ...jobOverviews];
}
/**
* Get jobs with overview and filtering
*/
async getJobs(params) {
const { name, state, search, skip = 0, limit = 50 } = params;
const [overview, result] = await Promise.all([
this.agenda.getJobsOverview(),
this.agenda.queryJobs({
name: name || undefined,
state: state,
search: search || undefined,
skip,
limit,
sort: { nextRunAt: 'desc' }
})
]);
const transformedJobs = result.jobs.map((job) => this.transformJob(job));
const transformedOverview = this.transformOverview(overview);
const totalPages = Math.ceil(result.total / limit) || 1;
return {
overview: transformedOverview,
jobs: transformedJobs,
total: result.total,
totalPages
};
}
/**
* Requeue jobs by creating new instances
*/
async requeueJobs(ids) {
if (!ids || ids.length === 0) {
return { requeuedCount: 0 };
}
const { jobs } = await this.agenda.queryJobs({ ids });
let requeuedCount = 0;
for (const job of jobs) {
await this.agenda.now(job.name, job.data);
requeuedCount++;
}
return { requeuedCount };
}
/**
* Retry jobs by setting their nextRunAt to now (reuses existing job)
*/
async retryJobs(ids) {
if (!ids || ids.length === 0) {
return { retriedCount: 0 };
}
const { jobs } = await this.agenda.queryJobs({ ids });
let retriedCount = 0;
for (const job of jobs) {
job.nextRunAt = new Date();
job.lockedAt = undefined;
job.failedAt = undefined;
job.failReason = undefined;
job.failCount = undefined;
await this.agenda.db.saveJob(job, { lastModifiedBy: 'agendash' });
retriedCount++;
}
return { retriedCount };
}
/**
* Delete jobs by ID
*/
async deleteJobs(ids) {
if (!ids || ids.length === 0) {
return { deleted: false, deletedCount: 0 };
}
const deletedCount = await this.agenda.cancel({ ids });
return {
deleted: deletedCount > 0,
deletedCount
};
}
/**
* Create a new job
*/
async createJob(options) {
const { jobName, jobSchedule, jobRepeatEvery, jobData } = options;
if (!jobName) {
throw new Error('jobName is required');
}
if (jobRepeatEvery) {
await this.agenda.every(jobRepeatEvery, jobName, jobData);
}
else if (jobSchedule) {
await this.agenda.schedule(jobSchedule, jobName, jobData);
}
else {
await this.agenda.now(jobName, jobData);
}
return { created: true };
}
/**
* Get running stats from the Agenda processor
*/
async getStats(fullDetails = false) {
return this.agenda.getRunningStats(fullDetails);
}
/**
* Pause jobs by ID (disables them so they won't run)
*/
async pauseJobs(ids) {
if (!ids || ids.length === 0) {
return { pausedCount: 0 };
}
const pausedCount = await this.agenda.disable({ ids });
return { pausedCount };
}
/**
* Resume jobs by ID (re-enables them so they can run)
*/
async resumeJobs(ids) {
if (!ids || ids.length === 0) {
return { resumedCount: 0 };
}
const resumedCount = await this.agenda.enable({ ids });
return { resumedCount };
}
/**
* Check if state notifications are available
*/
hasStateNotifications() {
// Access the notification channel via the internal property
// @ts-expect-error Accessing private property for state notification check
const channel = this.agenda.notificationChannel;
return !!channel?.subscribeState;
}
/**
* Create a subscription to job state notifications for real-time updates (SSE).
* This subscribes directly to the notification channel, bypassing the event re-emitting.
*
* @param onNotification - Callback function called for each state notification
* @returns Unsubscribe function to stop receiving notifications
* @throws Error if notification channel doesn't support state subscriptions
*/
createStateStream(onNotification) {
// Access the notification channel via the internal property
// @ts-expect-error Accessing private property for state subscription
const channel = this.agenda.notificationChannel;
if (!channel?.subscribeState) {
throw new Error('Notification channel does not support state subscriptions');
}
return channel.subscribeState(onNotification);
}
/**
* Check if persistent job logging is enabled
*/
hasLogging() {
return this.agenda.hasJobLogger();
}
/**
* Get job log entries with filtering and pagination
*/
async getLogs(params) {
if (!this.agenda.hasJobLogger()) {
return { entries: [], total: 0, loggingEnabled: false };
}
const query = {
jobId: params.jobId,
jobName: params.jobName,
limit: params.limit ?? 50,
offset: params.offset ?? 0,
sort: params.sort ?? 'desc'
};
if (params.level) {
const levels = params.level.split(',');
query.level = levels.length === 1 ? levels[0] : levels;
}
if (params.event) {
const events = params.event.split(',');
query.event = events.length === 1 ? events[0] : events;
}
if (params.from) {
query.from = new Date(params.from);
}
if (params.to) {
query.to = new Date(params.to);
}
const result = await this.agenda.getLogs(query);
return {
entries: result.entries.map(entry => ({
...entry,
timestamp: entry.timestamp.toISOString()
})),
total: result.total,
loggingEnabled: true
};
}
}
//# sourceMappingURL=AgendashController.js.map