feat: show cronjobs in acp (#14068)

* feat: show cronjobs in acp

add a wrapper for cronjobs and display in acp

* test: add running to spec

* test: fix running, simplify calls

* test: prevent crash on stopJob
This commit is contained in:
Barış Uşaklı
2026-03-06 20:42:38 -05:00
committed by GitHub
parent 9e2c6b67a8
commit 3c0a654012
23 changed files with 352 additions and 116 deletions

View File

@@ -62,6 +62,7 @@
"connect-redis": "9.0.0", "connect-redis": "9.0.0",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",
"cron": "4.4.0", "cron": "4.4.0",
"cronstrue": "3.13.0",
"cropperjs": "1.6.2", "cropperjs": "1.6.2",
"csrf-sync": "4.2.1", "csrf-sync": "4.2.1",
"daemon": "1.1.0", "daemon": "1.1.0",

View File

@@ -0,0 +1,8 @@
{
"jobs": "Jobs",
"job-name": "Job Name",
"schedule": "Schedule",
"next-run": "Next Run",
"last-duration": "Last Duration",
"running": "Running"
}

View File

@@ -78,6 +78,7 @@
"advanced/logs": "Logs", "advanced/logs": "Logs",
"advanced/errors": "Errors", "advanced/errors": "Errors",
"advanced/cache": "Cache", "advanced/cache": "Cache",
"advanced/jobs": "Jobs",
"development/logger": "Logger", "development/logger": "Logger",
"development/info": "Info", "development/info": "Info",

View File

@@ -186,6 +186,8 @@ paths:
$ref: 'read/admin/advanced/cache.yaml' $ref: 'read/admin/advanced/cache.yaml'
/api/admin/advanced/cache/dump: /api/admin/advanced/cache/dump:
$ref: 'read/admin/advanced/cache/dump.yaml' $ref: 'read/admin/advanced/cache/dump.yaml'
/api/admin/advanced/jobs:
$ref: 'read/admin/advanced/jobs.yaml'
/api/admin/development/logger: /api/admin/development/logger:
$ref: 'read/admin/development/logger.yaml' $ref: 'read/admin/development/logger.yaml'
/api/admin/development/info: /api/admin/development/info:

View File

@@ -0,0 +1,35 @@
get:
tags:
- admin
summary: Get cron job info
responses:
"200":
description: ""
content:
application/json:
schema:
allOf:
- type: object
properties:
jobs:
type: array
items:
type: object
properties:
name:
type: string
cronTime:
type: string
cronTimeHuman:
type: string
nextRun:
type: integer
nextRunISO:
type: integer
duration:
type: integer
durationReadable:
type: string
running:
type: boolean
- $ref: ../../../components/schemas/CommonProps.yaml#/CommonProps

View File

@@ -1,46 +1,55 @@
'use strict'; 'use strict';
const winston = require('winston');
const { CronJob } = require('cron');
const db = require('../database'); const db = require('../database');
const meta = require('../meta'); const meta = require('../meta');
const topics = require('../topics'); const topics = require('../topics');
const utils = require('../utils'); const utils = require('../utils');
const cron = require('../cron');
const activitypub = module.parent.exports; const activitypub = module.parent.exports;
const Jobs = module.exports; const Jobs = module.exports;
Jobs.start = () => { Jobs.start = async () => {
activitypub.helpers.log('[activitypub/jobs] Registering jobs.'); activitypub.helpers.log('[activitypub/jobs] Registering jobs.');
async function tryCronJob(method) { async function tryCronJob(method) {
if (!meta.config.activitypubEnabled) { if (meta.config.activityPubEnabled) {
return;
}
try {
await method(); await method();
} catch (err) {
winston.error(err.stack);
} }
} }
new CronJob('0 0 * * *', async () => {
await tryCronJob(async () => {
await activitypub.notes.prune();
await db.sortedSetsRemoveRangeByScore(['activities:datetime'], '-inf', Date.now() - 604800000);
});
}, null, true, null, null, false); // change last argument to true for debugging
new CronJob('*/30 * * * *', async () => { await cron.addJob({
await tryCronJob(activitypub.actors.prune); name: 'ap:notes:prune',
}, null, true, null, null, false); // change last argument to true for debugging cronTime: '0 0 * * *',
runOnInit: false,
onTick: async () => {
await tryCronJob(async () => {
await activitypub.notes.prune();
await db.sortedSetsRemoveRangeByScore(['activities:datetime'], '-inf', Date.now() - 604800000);
});
},
});
new CronJob('0 * * * * *', async () => { await cron.addJob({
await tryCronJob(retryFailedMessages); name: 'ap:actors:prune',
}, null, true, null, null, false); // change last argument to true for debugging cronTime: '*/30 * * * *',
runOnInit: false,
onTick: async () => await tryCronJob(activitypub.actors.prune),
});
new CronJob('15 * * * *', async () => { await cron.addJob({
await tryCronJob(backfill); name: 'ap:retry:send',
}, null, true, null, null, false); // change last argument to true for debugging cronTime: '0 * * * * *',
runOnInit: false,
onTick: async () => await tryCronJob(retryFailedMessages),
});
await cron.addJob({
name: 'ap:backfill',
cronTime: '15 * * * *',
runOnInit: false,
onTick: async () => await tryCronJob(backfill),
});
}; };
async function retryFailedMessages() { async function retryFailedMessages() {

View File

@@ -1,6 +1,5 @@
'use strict'; 'use strict';
const cronJob = require('cron').CronJob;
const winston = require('winston'); const winston = require('winston');
const nconf = require('nconf'); const nconf = require('nconf');
const util = require('util'); const util = require('util');
@@ -12,6 +11,7 @@ const db = require('./database');
const utils = require('./utils'); const utils = require('./utils');
const plugins = require('./plugins'); const plugins = require('./plugins');
const pubsub = require('./pubsub'); const pubsub = require('./pubsub');
const cron = require('./cron');
const Analytics = module.exports; const Analytics = module.exports;
@@ -32,19 +32,28 @@ const runJobs = nconf.get('runJobs');
Analytics.pause = false; Analytics.pause = false;
Analytics.init = async function () { Analytics.init = async function () {
new cronJob('*/10 * * * * *', (async () => { await cron.addJob({
if (Analytics.pause) return; name: 'analytics:publish',
publishLocalAnalytics(); cronTime: '*/10 * * * * *',
if (runJobs) { runOnAllNodes: true,
await sleep(2000); onTick: async () => {
await Analytics.writeData(); if (Analytics.pause) return;
} publishLocalAnalytics();
}), null, true); if (runJobs) {
await sleep(2000);
await Analytics.writeData();
}
},
});
if (runJobs) { if (runJobs) {
new cronJob('*/30 * * * *', (async () => { await cron.addJob({
await db.sortedSetsRemoveRangeByScore(['ip:recent'], '-inf', Date.now() - 172800000); name: 'prune:ip:recent',
}), null, true); cronTime: '*/30 * * * *',
onTick: async () => {
await db.sortedSetsRemoveRangeByScore(['ip:recent'], '-inf', Date.now() - 172800000);
},
});
} }
if (runJobs) { if (runJobs) {

View File

@@ -25,6 +25,7 @@ const adminController = {
errors: require('./admin/errors'), errors: require('./admin/errors'),
database: require('./admin/database'), database: require('./admin/database'),
cache: require('./admin/cache'), cache: require('./admin/cache'),
jobs: require('./admin/jobs'),
plugins: require('./admin/plugins'), plugins: require('./admin/plugins'),
settings: require('./admin/settings'), settings: require('./admin/settings'),
logger: require('./admin/logger'), logger: require('./admin/logger'),

View File

@@ -0,0 +1,12 @@
'use strict';
const jobsController = module.exports;
const cron = require('../../cron');
jobsController.get = async function (req, res) {
const jobs = await cron.getJobs();
res.render('admin/advanced/jobs', { jobs });
};

98
src/cron.js Normal file
View File

@@ -0,0 +1,98 @@
'use strict';
const nconf = require('nconf');
const { CronJob } = require('cron');
const cronstrue = require('cronstrue');
const winston = require('winston');
const db = require('./database');
const utils = require('./utils');
const jobs = Object.create(null);
exports.deleteJobs = async function () {
const jobs = await db.getSortedSetRange('cronJobs', 0, -1);
await db.deleteAll(jobs.map(name => `cronJob:${name}`));
await db.delete('cronJobs');
};
exports.addJob = async function (options) {
const {
name,
cronTime,
onTick,
onComplete = null,
start = true,
runOnInit = false,
runOnAllNodes = false,
} = options;
const isJobEnabled = nconf.get('runJobs');
if (!isJobEnabled && !runOnAllNodes) {
return;
}
if (!name || !cronTime || typeof onTick !== 'function') {
throw new Error('[cron] Invalid options');
}
if (Object.hasOwn(jobs, name)) {
throw new Error('[cron] Job with that name already exists');
}
const job = new CronJob(cronTime, async function () {
const start = Date.now();
try {
await db.setObjectField(`cronJob:${name}`, 'running', 1);
await onTick();
await db.deleteObjectField(`cronJob:${name}`, 'lastError');
} catch (err) {
winston.error(`[cron] ${err.stack}`);
await db.setObjectField(`cronJob:${name}`, 'lastError', err.stack);
} finally {
await db.setObject(`cronJob:${name}`, {
running: 0,
duration: Date.now() - start,
nextRun: job.nextDate().toMillis(),
});
}
}, onComplete, start, null, null, runOnInit);
jobs[name] = job;
await db.sortedSetAdd('cronJobs', Date.now(), name);
await db.setObject(`cronJob:${name}`, {
name,
cronTime,
cronTimeHuman: cronstrue.toString(cronTime),
nextRun: job.nextDate().toMillis(),
running: runOnInit ? 1 : 0,
});
winston.verbose(`[cron/jobs] Registered job: ${name} (${cronTime})`);
return job;
};
exports.getJobs = async function () {
const jobNames = await db.getSortedSetRange('cronJobs', 0, -1);
const jobs = await db.getObjects(jobNames.map(name => `cronJob:${name}`));
jobs.forEach((job) => {
if (job) {
job.running = parseInt(job.running, 10) === 1;
job.duration = job.duration || 0;
job.durationReadable = formatDuration(job.duration);
job.nextRunISO = utils.toISOString(job.nextRun);
}
});
jobs.sort((a, b) => b.cronTimeHuman.localeCompare(a.cronTimeHuman));
return jobs;
};
function formatDuration(ms) {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes > 0) {
return `${minutes}m${String(seconds).padStart(2, '0')}s`;
}
return `${seconds}s`;
}

View File

@@ -3,13 +3,13 @@
const nconf = require('nconf'); const nconf = require('nconf');
const winston = require('winston'); const winston = require('winston');
const validator = require('validator'); const validator = require('validator');
const cronJob = require('cron').CronJob;
const { setTimeout } = require('timers/promises'); const { setTimeout } = require('timers/promises');
const db = require('../database'); const db = require('../database');
const analytics = require('../analytics'); const analytics = require('../analytics');
const pubsub = require('../pubsub'); const pubsub = require('../pubsub');
const utils = require('../utils'); const utils = require('../utils');
const cron = require('../cron');
const Errors = module.exports; const Errors = module.exports;
@@ -19,13 +19,18 @@ let counters = {};
let total = {}; let total = {};
Errors.init = async function () { Errors.init = async function () {
new cronJob('0 * * * * *', async () => { await cron.addJob({
publishLocalErrors(); name: 'errors:publish',
if (runJobs) { cronTime: '0 * * * * *',
await setTimeout(2000); runOnAllNodes: true,
await Errors.writeData(); onTick: async () => {
} publishLocalErrors();
}, null, true); if (runJobs) {
await setTimeout(2000);
await Errors.writeData();
}
},
});
if (runJobs) { if (runJobs) {
pubsub.on('errors:publish', (data) => { pubsub.on('errors:publish', (data) => {

View File

@@ -3,7 +3,6 @@
const async = require('async'); const async = require('async');
const winston = require('winston'); const winston = require('winston');
const cron = require('cron').CronJob;
const nconf = require('nconf'); const nconf = require('nconf');
const _ = require('lodash'); const _ = require('lodash');
@@ -18,6 +17,7 @@ const plugins = require('./plugins');
const utils = require('./utils'); const utils = require('./utils');
const emailer = require('./emailer'); const emailer = require('./emailer');
const ttlCache = require('./cache/ttl'); const ttlCache = require('./cache/ttl');
const cron = require('./cron');
const Notifications = module.exports; const Notifications = module.exports;
@@ -73,9 +73,13 @@ Notifications.getAllNotificationTypes = async function () {
return results.types.concat(results.privilegedTypes); return results.types.concat(results.privilegedTypes);
}; };
Notifications.startJobs = function () { Notifications.startJobs = async function () {
winston.verbose('[notifications.init] Registering jobs.'); winston.verbose('[notifications.init] Registering jobs.');
new cron('*/30 * * * *', Notifications.prune, null, true); await cron.addJob({
name: 'notifications:prune',
cronTime: '*/30 * * * *',
onTick: Notifications.prune,
});
}; };
Notifications.get = async function (nid) { Notifications.get = async function (nid) {

View File

@@ -3,18 +3,20 @@
const nconf = require('nconf'); const nconf = require('nconf');
const winston = require('winston'); const winston = require('winston');
const crypto = require('crypto'); const crypto = require('crypto');
const cronJob = require('cron').CronJob;
const request = require('../request'); const request = require('../request');
const cron = require('../cron');
const pkg = require('../../package.json'); const pkg = require('../../package.json');
const meta = require('../meta'); const meta = require('../meta');
module.exports = function (Plugins) { module.exports = function (Plugins) {
Plugins.startJobs = function () { Plugins.startJobs = async function () {
new cronJob('0 0 0 * * *', (async () => { await cron.addJob({
await Plugins.submitUsageData(); name: 'plugins:submitUsageData',
}), null, true); cronTime: '0 0 0 * * *',
onTick: Plugins.submitUsageData,
});
}; };
Plugins.submitUsageData = async function () { Plugins.submitUsageData = async function () {

View File

@@ -7,7 +7,6 @@ const path = require('path');
const winston = require('winston'); const winston = require('winston');
const mime = require('mime'); const mime = require('mime');
const validator = require('validator'); const validator = require('validator');
const cronJob = require('cron').CronJob;
const chalk = require('chalk'); const chalk = require('chalk');
const db = require('../database'); const db = require('../database');
@@ -16,6 +15,7 @@ const user = require('../user');
const topics = require('../topics'); const topics = require('../topics');
const file = require('../file'); const file = require('../file');
const meta = require('../meta'); const meta = require('../meta');
const cron = require('../cron');
module.exports = function (Posts) { module.exports = function (Posts) {
Posts.uploads = {}; Posts.uploads = {};
@@ -30,18 +30,26 @@ module.exports = function (Posts) {
return fullPath.startsWith(pathPrefix) && await file.exists(fullPath) ? filePath : false; return fullPath.startsWith(pathPrefix) && await file.exists(fullPath) ? filePath : false;
}))).filter(Boolean); }))).filter(Boolean);
const runJobs = nconf.get('runJobs'); Posts.uploads.startJobs = async function () {
if (runJobs) { const runJobs = nconf.get('runJobs');
new cronJob('0 2 * * 0', async () => { if (!runJobs) {
const orphans = await Posts.uploads.cleanOrphans(); return;
if (orphans.length) { }
winston.info(`[posts/uploads] Deleting ${orphans.length} orphaned uploads...`);
orphans.forEach((relPath) => { await cron.addJob({
process.stdout.write(`${chalk.red(' - ')} ${relPath}`); name: 'posts:uploads:cleanupOrphans',
}); cronTime: '0 2 * * 0',
} onTick: async () => {
}, null, true); const orphans = await Posts.uploads.cleanOrphans();
} if (orphans.length) {
winston.info(`[posts/uploads] Deleting ${orphans.length} orphaned uploads...`);
orphans.forEach((relPath) => {
process.stdout.write(`${chalk.red(' - ')} ${relPath}`);
});
}
},
});
};
Posts.uploads.sync = async function (pid) { Posts.uploads.sync = async function (pid) {
// Scans a post's content and updates sorted set of uploads // Scans a post's content and updates sorted set of uploads

View File

@@ -74,6 +74,7 @@ module.exports = function (app, name, middleware, controllers) {
helpers.setupAdminPageRoute(app, `/${name}/advanced/errors`, middlewares, controllers.admin.errors.get); helpers.setupAdminPageRoute(app, `/${name}/advanced/errors`, middlewares, controllers.admin.errors.get);
helpers.setupAdminPageRoute(app, `/${name}/advanced/errors/export`, middlewares, controllers.admin.errors.export); helpers.setupAdminPageRoute(app, `/${name}/advanced/errors/export`, middlewares, controllers.admin.errors.export);
helpers.setupAdminPageRoute(app, `/${name}/advanced/cache`, middlewares, controllers.admin.cache.get); helpers.setupAdminPageRoute(app, `/${name}/advanced/cache`, middlewares, controllers.admin.cache.get);
helpers.setupAdminPageRoute(app, `/${name}/advanced/jobs`, middlewares, controllers.admin.jobs.get);
helpers.setupAdminPageRoute(app, `/${name}/development/logger`, middlewares, controllers.admin.logger.get); helpers.setupAdminPageRoute(app, `/${name}/development/logger`, middlewares, controllers.admin.logger.get);
helpers.setupAdminPageRoute(app, `/${name}/development/info`, middlewares, controllers.admin.info.get); helpers.setupAdminPageRoute(app, `/${name}/development/info`, middlewares, controllers.admin.info.get);

View File

@@ -35,11 +35,13 @@ start.start = async function () {
await sockets.init(webserver.server); await sockets.init(webserver.server);
if (nconf.get('runJobs')) { if (nconf.get('runJobs')) {
require('./notifications').startJobs(); await require('./cron').deleteJobs();
require('./user').startJobs(); await require('./notifications').startJobs();
require('./plugins').startJobs(); await require('./user').startJobs();
require('./topics').scheduled.startJobs(); await require('./plugins').startJobs();
require('./activitypub').jobs.start(); await require('./topics').scheduled.startJobs();
await require('./posts').uploads.startJobs();
await require('./activitypub').jobs.start();
await db.delete('locks'); await db.delete('locks');
} }

View File

@@ -2,7 +2,6 @@
const _ = require('lodash'); const _ = require('lodash');
const winston = require('winston'); const winston = require('winston');
const { CronJob } = require('cron');
const db = require('../database'); const db = require('../database');
const posts = require('../posts'); const posts = require('../posts');
@@ -13,18 +12,17 @@ const groups = require('../groups');
const user = require('../user'); const user = require('../user');
const activitypub = require('../activitypub'); const activitypub = require('../activitypub');
const plugins = require('../plugins'); const plugins = require('../plugins');
const cron = require('../cron');
const Scheduled = module.exports; const Scheduled = module.exports;
Scheduled.startJobs = function () { Scheduled.startJobs = async function () {
winston.verbose('[scheduled topics] Starting jobs.'); winston.verbose('[scheduled topics] Starting jobs.');
new CronJob('*/1 * * * *', async () => { await cron.addJob({
try { name: 'topics:publish:scheduled',
await Scheduled.handleExpired(); cronTime: '*/1 * * * *',
} catch (err) { onTick: Scheduled.handleExpired,
winston.error(err.stack); });
}
}, null, true);
}; };
Scheduled.handleExpired = async function () { Scheduled.handleExpired = async function () {

View File

@@ -2,7 +2,6 @@
const validator = require('validator'); const validator = require('validator');
const winston = require('winston'); const winston = require('winston');
const cronJob = require('cron').CronJob;
const db = require('../database'); const db = require('../database');
const meta = require('../meta'); const meta = require('../meta');
@@ -14,14 +13,6 @@ const slugify = require('../slugify');
const plugins = require('../plugins'); const plugins = require('../plugins');
module.exports = function (User) { module.exports = function (User) {
new cronJob('0 * * * *', (async () => {
try {
await User.autoApprove();
} catch (err) {
winston.error(err.stack);
}
}), null, true);
User.createOrQueue = async function (req, userData, opts = {}) { User.createOrQueue = async function (req, userData, opts = {}) {
User.checkUsernameLength(userData.username); User.checkUsernameLength(userData.username);
const queue = await User.shouldQueueUser(req.ip); const queue = await User.shouldQueueUser(req.ip);

View File

@@ -1,14 +1,14 @@
'use strict'; 'use strict';
const winston = require('winston'); const winston = require('winston');
const cronJob = require('cron').CronJob;
const db = require('../database'); const db = require('../database');
const meta = require('../meta'); const meta = require('../meta');
const cron = require('../cron');
const jobs = {}; const jobs = {};
module.exports = function (User) { module.exports = function (User) {
User.startJobs = function () { User.startJobs = async function () {
winston.verbose('[user/jobs] (Re-)starting jobs...'); winston.verbose('[user/jobs] (Re-)starting jobs...');
let { digestHour } = meta.config; let { digestHour } = meta.config;
@@ -22,20 +22,31 @@ module.exports = function (User) {
User.stopJobs(); User.stopJobs();
startDigestJob('digest.daily', `0 ${digestHour} * * *`, 'day'); await startDigestJob('digest.daily', `0 ${digestHour} * * *`, 'day');
startDigestJob('digest.weekly', `0 ${digestHour} * * 0`, 'week'); await startDigestJob('digest.weekly', `0 ${digestHour} * * 0`, 'week');
startDigestJob('digest.monthly', `0 ${digestHour} 1 * *`, 'month'); await startDigestJob('digest.monthly', `0 ${digestHour} 1 * *`, 'month');
jobs['reset.clean'] = new cronJob('0 0 * * *', User.reset.clean, null, true); jobs['reset.clean'] = await cron.addJob({
winston.verbose('[user/jobs] Starting job (reset.clean)'); name: 'user:reset:clean',
cronTime: '0 0 * * *',
onTick: User.reset.clean,
});
await cron.addJob({
name: 'user:autoApprove',
cronTime: '0 * * * *',
onTick: User.autoApprove,
});
winston.verbose(`[user/jobs] jobs started`); winston.verbose(`[user/jobs] jobs started`);
}; };
function startDigestJob(name, cronString, term) { async function startDigestJob(name, cronString, term) {
jobs[name] = new cronJob(cronString, (async () => { const newJob = await cron.addJob({
winston.verbose(`[user/jobs] Digest job (${name}) started.`); name,
try { cronTime: cronString,
onTick: async () => {
winston.verbose(`[user/jobs] Digest job (${name}) started.`);
if (name === 'digest.weekly') { if (name === 'digest.weekly') {
const counter = await db.increment('biweeklydigestcounter'); const counter = await db.increment('biweeklydigestcounter');
if (counter % 2) { if (counter % 2) {
@@ -43,24 +54,27 @@ module.exports = function (User) {
} }
} }
await User.digest.execute({ interval: term }); await User.digest.execute({ interval: term });
} catch (err) { },
winston.error(err.stack); });
} if (newJob) {
}), null, true); jobs[name] = newJob;
winston.verbose(`[user/jobs] Starting job (${name})`); }
} }
User.stopJobs = function () { User.stopJobs = function () {
let terminated = 0; let terminated = 0;
// Terminate any active cron jobs // Terminate any active cron jobs
for (const jobId of Object.keys(jobs)) { for (const [name, job] of Object.entries(jobs)) {
winston.verbose(`[user/jobs] Terminating job (${jobId})`); winston.info(`[user/jobs] Terminating job (${name})`);
jobs[jobId].stop(); if (job) {
delete jobs[jobId]; job.stop();
delete jobs[name];
}
terminated += 1; terminated += 1;
} }
if (terminated > 0) { if (terminated > 0) {
winston.verbose(`[user/jobs] ${terminated} jobs terminated`); winston.info(`[user/jobs] ${terminated} jobs terminated`);
} }
}; };
}; };

View File

@@ -23,7 +23,7 @@
<th class="text-end"><i class="fa-solid invisible fa-sort-down"></i> <a href="#" class="text-reset">hit ratio</a></th> <th class="text-end"><i class="fa-solid invisible fa-sort-down"></i> <a href="#" class="text-reset">hit ratio</a></th>
<th class="text-end"><i class="fa-solid invisible fa-sort-down"></i> <a href="#" class="text-reset">hits/sec</a></th> <th class="text-end"><i class="fa-solid invisible fa-sort-down"></i> <a href="#" class="text-reset">hits/sec</a></th>
<th class="text-end"><i class="fa-solid invisible fa-sort-down"></i> <a href="#" class="text-reset">ttl</a></th> <th class="text-end"><i class="fa-solid invisible fa-sort-down"></i> <a href="#" class="text-reset">ttl</a></th>
<th></td> <th></th>
</tr> </tr>
</thead> </thead>
<tbody class="text-xs text-tabular"> <tbody class="text-xs text-tabular">

View File

@@ -0,0 +1,36 @@
<div class="d-flex flex-column gap-2 px-lg-4">
<div class="d-flex border-bottom py-2 m-0 sticky-top acp-page-main-header align-items-center justify-content-between flex-wrap gap-2">
<div class="">
<h4 class="fw-bold tracking-tight mb-0">[[admin/advanced/jobs:jobs]]</h4>
</div>
</div>
<div>
<div class="table-responsive">
<table id="jobs-table" class="table">
<thead>
<tr class="text-sm">
<th>[[admin/advanced/jobs:job-name]]</th>
<th>[[admin/advanced/jobs:schedule]]</th>
<th>[[admin/advanced/jobs:next-run]]</th>
<th class="text-end">[[admin/advanced/jobs:last-duration]]</th>
<th class="text-center">[[admin/advanced/jobs:running]]</th>
</tr>
</thead>
<tbody class="text-xs text-tabular">
{{{ each jobs }}}
<tr class="align-middle">
<td>{./name}</td>
<td>{./cronTimeHuman} <span class="text-secondary">({./cronTime})</span></td>
<td><span class="timeago" title="{./nextRunISO}"></span></td>
<td class="text-end">{./durationReadable}</td>
<td class="text-center">{{{ if ./running }}}Yes{{{ else }}}No{{{ end }}}</td>
</tr>
{{{ end }}}
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -190,6 +190,7 @@
<a class="btn btn-ghost btn-sm text-start" href="{relative_path}/admin/advanced/events">[[admin/menu:advanced/events]]</a> <a class="btn btn-ghost btn-sm text-start" href="{relative_path}/admin/advanced/events">[[admin/menu:advanced/events]]</a>
<a class="btn btn-ghost btn-sm text-start" href="{relative_path}/admin/advanced/hooks">[[admin/menu:advanced/hooks]]</a> <a class="btn btn-ghost btn-sm text-start" href="{relative_path}/admin/advanced/hooks">[[admin/menu:advanced/hooks]]</a>
<a class="btn btn-ghost btn-sm text-start" href="{relative_path}/admin/advanced/cache">[[admin/menu:advanced/cache]]</a> <a class="btn btn-ghost btn-sm text-start" href="{relative_path}/admin/advanced/cache">[[admin/menu:advanced/cache]]</a>
<a class="btn btn-ghost btn-sm text-start" href="{relative_path}/admin/advanced/jobs">[[admin/menu:advanced/jobs]]</a>
<a class="btn btn-ghost btn-sm text-start" href="{relative_path}/admin/advanced/errors">[[admin/menu:advanced/errors]]</a> <a class="btn btn-ghost btn-sm text-start" href="{relative_path}/admin/advanced/errors">[[admin/menu:advanced/errors]]</a>
<a class="btn btn-ghost btn-sm text-start" href="{relative_path}/admin/advanced/logs">[[admin/menu:advanced/logs]]</a> <a class="btn btn-ghost btn-sm text-start" href="{relative_path}/admin/advanced/logs">[[admin/menu:advanced/logs]]</a>
{{{ if env }}} {{{ if env }}}

View File

@@ -2297,14 +2297,12 @@ describe('User', () => {
}); });
describe('user jobs', () => { describe('user jobs', () => {
it('should start user jobs', (done) => { it('should start user jobs', async () => {
User.startJobs(); await User.startJobs();
done();
}); });
it('should stop user jobs', (done) => { it('should stop user jobs', async () => {
User.stopJobs(); User.stopJobs();
done();
}); });
it('should send digest', (done) => { it('should send digest', (done) => {