diff --git a/app/composables/ving/useAdminLinks.mjs b/app/composables/ving/useAdminLinks.mjs index ff288772..b69ef9d5 100644 --- a/app/composables/ving/useAdminLinks.mjs +++ b/app/composables/ving/useAdminLinks.mjs @@ -8,6 +8,7 @@ export default () => { const links = computed(() => { const out = [ + { label: 'Cron Jobs', to: '/cronjob', icon: 'ph:clock' }, { label: 'System Wide Alert', to: '/system-wide-alert', icon: 'ph:megaphone' }, { label: 'Users', to: '/user/admin', icon: 'ph:users' }, ]; diff --git a/app/pages/cronjob/[id]/edit.vue b/app/pages/cronjob/[id]/edit.vue new file mode 100644 index 00000000..b2caf7f1 --- /dev/null +++ b/app/pages/cronjob/[id]/edit.vue @@ -0,0 +1,67 @@ + + Edit {{cronjob.props?.id}} + + + + + + + + + + + + + + + + + + Id: {{cronjob.props?.id}} + + Created At: {{formatDateTime(cronjob.props.createdAt)}} + + Updated At: {{formatDateTime(cronjob.props.updatedAt)}} + + + + + Delete + + + + + + + \ No newline at end of file diff --git a/app/pages/cronjob/index.vue b/app/pages/cronjob/index.vue new file mode 100644 index 00000000..914dc05b --- /dev/null +++ b/app/pages/cronjob/index.vue @@ -0,0 +1,63 @@ + + Cron Jobs + + + + + + + cronjobs.sortDataTable(e)"> + + + + + + + {{ enum2label(slotProps.data.props.enabled, cronjobs.propsOptions.enabled) }} + + + + + + + + + + + + + + + + + + Create Cron Job + + + + + + + + + \ No newline at end of file diff --git a/server/api/v1/cronjob/[id]/index.delete.mjs b/server/api/v1/cronjob/[id]/index.delete.mjs new file mode 100644 index 00000000..6b9a4f5f --- /dev/null +++ b/server/api/v1/cronjob/[id]/index.delete.mjs @@ -0,0 +1,12 @@ +import { useKind } from '#ving/record/utils.mjs'; +import { obtainSession, describeParams } from '#ving/utils/rest.mjs'; +import {defineEventHandler, getRouterParams} from 'h3'; +export default defineEventHandler(async (event) => { + const cronjobs = await useKind('CronJob'); + const { id } = getRouterParams(event); + const cronjob = await cronjobs.findOrDie(id); + const session = obtainSession(event); + await cronjob.canEdit(session); + await cronjob.delete(); + return cronjob.describe(describeParams(event, session)); +}); \ No newline at end of file diff --git a/server/api/v1/cronjob/[id]/index.get.mjs b/server/api/v1/cronjob/[id]/index.get.mjs new file mode 100644 index 00000000..f04b03b8 --- /dev/null +++ b/server/api/v1/cronjob/[id]/index.get.mjs @@ -0,0 +1,9 @@ +import { useKind } from '#ving/record/utils.mjs'; +import { describeParams } from '#ving/utils/rest.mjs'; +import {defineEventHandler, getRouterParams} from 'h3'; +export default defineEventHandler(async (event) => { + const cronjobs = await useKind('CronJob'); + const { id } = getRouterParams(event); + const cronjob = await cronjobs.findOrDie(id); + return cronjob.describe(describeParams(event)); +}); \ No newline at end of file diff --git a/server/api/v1/cronjob/[id]/index.put.mjs b/server/api/v1/cronjob/[id]/index.put.mjs new file mode 100644 index 00000000..4dd567fe --- /dev/null +++ b/server/api/v1/cronjob/[id]/index.put.mjs @@ -0,0 +1,12 @@ +import { useKind } from '#ving/record/utils.mjs'; +import { describeParams, obtainSession, getBody } from '#ving/utils/rest.mjs'; +import {defineEventHandler, getRouterParams} from 'h3'; +export default defineEventHandler(async (event) => { + const cronjobs = await useKind('CronJob'); + const { id } = getRouterParams(event); + const cronjob = await cronjobs.findOrDie(id); + const session = obtainSession(event); + await cronjob.canEdit(session); + await cronjob.updateAndVerify(await getBody(event), session); + return cronjob.describe(describeParams(event, session)); +}); \ No newline at end of file diff --git a/server/api/v1/cronjob/index.get.mjs b/server/api/v1/cronjob/index.get.mjs new file mode 100644 index 00000000..544b6fb0 --- /dev/null +++ b/server/api/v1/cronjob/index.get.mjs @@ -0,0 +1,7 @@ +import { useKind } from '#ving/record/utils.mjs'; +import { describeListParams, describeListWhere } from '#ving/utils/rest.mjs'; +import {defineEventHandler} from 'h3'; +export default defineEventHandler(async (event) => { + const cronjobs = await useKind('CronJob'); + return await cronjobs.describeList(describeListParams(event), describeListWhere(event, cronjobs.describeListFilter())); +}); \ No newline at end of file diff --git a/server/api/v1/cronjob/index.post.mjs b/server/api/v1/cronjob/index.post.mjs new file mode 100644 index 00000000..8631f102 --- /dev/null +++ b/server/api/v1/cronjob/index.post.mjs @@ -0,0 +1,9 @@ +import { useKind } from '#ving/record/utils.mjs'; +import { describeParams, getBody, obtainSessionIfRole } from '#ving/utils/rest.mjs'; +import {defineEventHandler} from 'h3'; +export default defineEventHandler(async (event) => { + const cronjobs = await useKind('CronJob'); + const session = obtainSessionIfRole(event, 'verifiedEmail'); + const cronjob = await cronjobs.createAndVerify(await getBody(event), session); + return cronjob.describe(describeParams(event, session)); +}); \ No newline at end of file diff --git a/server/api/v1/cronjob/options.get.mjs b/server/api/v1/cronjob/options.get.mjs new file mode 100644 index 00000000..c12943f8 --- /dev/null +++ b/server/api/v1/cronjob/options.get.mjs @@ -0,0 +1,7 @@ +import { useKind } from '#ving/record/utils.mjs'; +import { describeParams } from '#ving/utils/rest.mjs'; +import {defineEventHandler} from 'h3'; +export default defineEventHandler(async (event) => { + const cronjobs = await useKind('CronJob'); + return cronjobs.mint().propOptions(describeParams(event), true); +}); \ No newline at end of file diff --git a/ving/docs/change-log.md b/ving/docs/change-log.md index a8c3b1a0..e374562f 100644 --- a/ving/docs/change-log.md +++ b/ving/docs/change-log.md @@ -8,6 +8,9 @@ outline: deep ### 2024-07-04 * Added `options` param to ving schema props. * Fixed generating pathing for pages. +* Added Cron Jobs subsystem. +* NOTE: Run a database migration to add the new cronjob table. +* Fixed: Can't have duplicate cron specs in redis. #175 ### 2024-07-01 * Added verifiedEmail field to the User CLI. diff --git a/ving/docs/rest/CronJob.md b/ving/docs/rest/CronJob.md new file mode 100644 index 00000000..4fa4ccc8 --- /dev/null +++ b/ving/docs/rest/CronJob.md @@ -0,0 +1,47 @@ +--- +outline: deep +--- +# CronJob +Run background [jobs](../subsystems/jobs) on a set schedule. + +## Filters + +| Prop | Queryable | Qualifier | Range | +| --- | --- | --- | --- | +| createdAt | No | No | Yes | +| updatedAt | No | No | Yes | +| schedule | No | Yes | No | +| handler | No | Yes | No | + +## Endpoints + +### List + +``` +GET /api/v1/cronjob +``` + +### Create +``` +POST /api/v1/cronjob +``` + +### Read +``` +GET /api/v1/cronjob/:id +``` + +### Update +``` +PUT /api/v1/cronjob/:id +``` + +### Delete +``` +DELETE /api/v1/cronjob/:id +``` + +### Options +``` +GET /api/v1/cronjob/options +``` \ No newline at end of file diff --git a/ving/docs/subsystems/jobs.md b/ving/docs/subsystems/jobs.md index c4e900da..4f66df43 100644 --- a/ving/docs/subsystems/jobs.md +++ b/ving/docs/subsystems/jobs.md @@ -65,3 +65,7 @@ To create a new job run: ./ving.mjs jobs -n MyNewHandler ``` +## Cron Jobs +You can set up jobs with a `cron` style schedule via the javascript API or the [CLI](cli). However, 2 jobs cannot have the same schedule, and you cannot set them up via the REST API. To get around this you can use the `CronJob` VingRecord which is accessible via the [REST API](../rest/CronJob) or using the built-in Admin UI. + +This system works by storing the configuration of your repeating jobs in the `CronJob` VingRecord. When the `CronJob` handler is run it will look for any `CronJob` records that have the same schedule and execute them. When there are no more scheduled jobs that run at that schedule, the CronJob handler will automatically remove the schedule from BullMQ. \ No newline at end of file diff --git a/ving/drizzle/schema/CronJob.mjs b/ving/drizzle/schema/CronJob.mjs new file mode 100644 index 00000000..b31ee50a --- /dev/null +++ b/ving/drizzle/schema/CronJob.mjs @@ -0,0 +1,20 @@ +import { boolean, mysqlEnum, mysqlTable, timestamp, datetime, uniqueIndex, unique, char, varchar, text, int, bigint, json, mediumText, foreignKey } from '#ving/drizzle/orm.mjs'; + + + +export const CronJobTable = mysqlTable('cronjobs', + { + id: bigint('id', {mode:'number', unsigned: true}).notNull().autoincrement().primaryKey(), + createdAt: timestamp('createdAt').defaultNow().notNull(), + updatedAt: timestamp('updatedAt').defaultNow().notNull().onUpdateNow(), + schedule: varchar('schedule', { length: 60 }).notNull().default('* * * * *'), + handler: varchar('handler', { length: 60 }).notNull().default('Test'), + params: json('params').notNull().default({}), + enabled: boolean('enabled').notNull().default(true), + note: text('note').notNull() + }, + (table) => ({ + + }) +); + diff --git a/ving/jobs/handlers/CronJob.mjs b/ving/jobs/handlers/CronJob.mjs new file mode 100644 index 00000000..4a7dd98d --- /dev/null +++ b/ving/jobs/handlers/CronJob.mjs @@ -0,0 +1,27 @@ +import ving from '#ving/index.mjs'; +import { getJobsForHandler, killJob } from "#ving/jobs/queue.mjs"; + +/** + * This handler executes all cron jobs at the same schedule in the CronJob VingRecord. + * @param {Object} A `BullMQ` job. + * @returns {boolean} `true` + */ +export default async function (job) { + ving.log('jobs').info(`Running CronJobs at schedule ${JSON.stringify(job.data.schedule)}`); + const cronJobs = await ving.useKind('CronJob'); + const records = await cronJobs.findMany({ schedule: job.data.schedule }); + if (records.length == 0) { + ving.log('jobs').info(`No CronJobs found at schedule ${JSON.stringify(job.data.schedule)}. Removing schedule.`); + const jobs = await getJobsForHandler('CronJob'); + for (const rjob of jobs) { + if (job.data.schedule == rjob.data.schedule) + await killJob(rjob.id); + } + } + else { + for (const record of records) { + await record.queueJob(); + } + } + return true; +} \ No newline at end of file diff --git a/ving/record/records/CronJob.mjs b/ving/record/records/CronJob.mjs new file mode 100644 index 00000000..e51cf056 --- /dev/null +++ b/ving/record/records/CronJob.mjs @@ -0,0 +1,66 @@ +import { VingRecord, VingKind, enum2options } from "#ving/record/VingRecord.mjs"; +import { getHandlerNames, addJob } from "#ving/jobs/queue.mjs"; + +/** Management of individual CronJobs. This is needed, because BullMQ won't allow multiple jobs with the + * same schedule to run as cron jobs. So this will execute the `CronJob` handler, which will in turn execute + * any number of cron jobs at that same schedule. This also gives a nice administrative UI for managing + * cron jobs. + * @class + */ +export class CronJobRecord extends VingRecord { + // add custom Record code here + + /** + * Used with the VingSchema to generate the options for the job handler name. + * @returns {Array} An array of options for the job handler names + * @example + * const options = await cronJob.handlerOptions() + */ + async handlerOptions() { + const handlerNames = getHandlerNames(); + const filteredHandlerNames = handlerNames.filter((h) => h != 'CronJob'); + return enum2options(filteredHandlerNames, filteredHandlerNames); + } + + /** + * Inserts the current record into the database then adds a BullMQ job to execute the `CronJob` handler. + * @example + * await cronJob.insert() + */ + async insert() { + await super.insert(); + await addJob('CronJob', { schedule: this.get('schedule') }, { cron: this.get('schedule') }); + } + + /** + * Updates the current record in the database then adds a BullMQ job to execute the `CronJob` handler. + * @example + * await cronJob.update() + */ + async update() { + await super.update(); + await addJob('CronJob', { schedule: this.get('schedule') }, { cron: this.get('schedule') }); + } + + // don't need a delete, because the `CronJob` handler will delete the job when it is done if there are no + // scheduled jobs with that schedule. + + /** + * Queues a job for this record. + * @example + * await cronJob.queueJob() + */ + async queueJob() { + if (this.get('enabled') == true) + await addJob(this.get('handler'), this.get('params')); + } +} + +/** Management of all CronJobs. + * @class + */ +export class CronJobKind extends VingKind { + // add custom Kind code here + + +} \ No newline at end of file diff --git a/ving/schema/map.mjs b/ving/schema/map.mjs index 99f072e4..8bcc4303 100644 --- a/ving/schema/map.mjs +++ b/ving/schema/map.mjs @@ -2,6 +2,7 @@ import { isUndefined } from '#ving/utils/identify.mjs'; import { ouch } from '#ving/utils/ouch.mjs'; import { userSchema } from "#ving/schema/schemas/User.mjs"; import { apikeySchema } from "#ving/schema/schemas/APIKey.mjs"; +import { cronJobSchema } from "#ving/schema/schemas/CronJob.mjs"; import { s3fileSchema } from "#ving/schema/schemas/S3File.mjs"; /** @@ -10,6 +11,7 @@ import { s3fileSchema } from "#ving/schema/schemas/S3File.mjs"; export const vingSchemas = [ userSchema, apikeySchema, + cronJobSchema, s3fileSchema, ]; diff --git a/ving/schema/schemas/CronJob.mjs b/ving/schema/schemas/CronJob.mjs new file mode 100644 index 00000000..4fb112d7 --- /dev/null +++ b/ving/schema/schemas/CronJob.mjs @@ -0,0 +1,70 @@ +import { baseSchemaProps, dbVarChar, zodString, dbBoolean, dbText, dbJson, zodJsonObject } from '../helpers.mjs'; + +export const cronJobSchema = { + kind: 'CronJob', + tableName: 'cronjobs', + owner: ['admin'], + props: [ + ...baseSchemaProps, + { + type: "string", + name: "schedule", + required: true, + unique: false, + length: 60, + default: '* * * * *', + filterQualifier: true, + db: (prop) => dbVarChar(prop), + zod: (prop) => zodString(prop), + view: [], + edit: ['owner'], + }, + { + type: "string", // could be an enum, but it would be annoying updating it every time one was added or removed + name: "handler", + options: 'handlerOptions', + required: true, + unique: false, + length: 60, + default: 'Test', + filterQualifier: true, + db: (prop) => dbVarChar(prop), + zod: (prop) => zodString(prop), + view: [], + edit: ['owner'], + }, + { + type: "json", + name: "params", + required: false, + default: '{}', + db: (prop) => dbJson(prop), + zod: (prop) => zodJsonObject(prop).passthrough(), // or replace .passthrough() with something like .extends({foo: z.string()}) + view: [], + edit: ['owner'], + }, + { + type: "boolean", + name: 'enabled', + required: true, + default: true, + filterQualifier: true, + db: (prop) => dbBoolean(prop), + enums: [false, true], + enumLabels: ['Not Enabled', 'Is Enabled'], + view: [], + edit: ['owner'], + }, + { + type: "string", + name: 'note', + required: false, + length: 65535, + default: '', + db: (prop) => dbText(prop), + zod: (prop) => zodString(prop), + view: [], + edit: ['owner'], + }, + ], +}; \ No newline at end of file