mirror of
https://github.com/itflow-org/itflow
synced 2026-08-04 22:57:14 +00:00
Cron Fix
This commit is contained in:
20
CHANGELOG.md
20
CHANGELOG.md
@@ -75,12 +75,20 @@ This file documents all notable changes made to ITFlow.
|
||||
plural filenames. Bookmarks or external links pointing at the old filenames will 404.
|
||||
### Major Changes
|
||||
|
||||
- **One cron entry instead of five.** `cron/cron.php` is now a dispatcher that runs every minute
|
||||
and decides which jobs are due, so scheduling lives in ITFlow rather than in the crontab and new
|
||||
jobs arrive with an update instead of an install note. Jobs are tracked in a new `cron_jobs`
|
||||
table, which means a job whose slot was missed runs at the next opportunity rather than waiting
|
||||
a day, and each job is locked for its own run so a slow mailbox or a long nightly run no longer
|
||||
delays anything else. The nightly work itself moved to `cron/nightly_tasks.php`.
|
||||
- **One cron entry instead of five, and a page to manage it.** `cron/cron.php` is now a dispatcher
|
||||
that runs every minute and decides which jobs are due, so scheduling lives in ITFlow rather than
|
||||
in the crontab and new jobs arrive with an update instead of an install note. Jobs are tracked in
|
||||
a new `cron_jobs` table, which means a job whose slot was missed runs at the next opportunity
|
||||
rather than waiting a day, and each job is locked for its own run so a slow mailbox or a long
|
||||
nightly run no longer delays anything else. The nightly work itself moved to
|
||||
`cron/nightly_tasks.php`.
|
||||
- **Settings > Cron.** A new admin page lists every job with its schedule, when it last ran, how
|
||||
long it took, how it ended, and when it is next due. Each job can be turned off, given a
|
||||
different frequency or time of day, and run on demand — Run Now hands the job to the next
|
||||
dispatch rather than running it in the browser, so it starts within a minute and still runs on
|
||||
the command line. The last error a job hit is kept until it is dismissed, rather than
|
||||
disappearing behind the next success, and the page says plainly when the crontab entry itself is
|
||||
missing — the dispatcher records a heartbeat every minute whether or not any job was due.
|
||||
|
||||
- **Ticket SLAs (optional).** SLAs define a response target and an optional resolution target,
|
||||
and are assigned per client and priority, with a global default and an explicit "no SLA"
|
||||
|
||||
@@ -85,7 +85,11 @@ One crontab entry runs everything:
|
||||
* * * * * php /path/to/itflow/cron/cron.php >/dev/null
|
||||
```
|
||||
|
||||
`cron/cron.php` is a dispatcher. It wakes every minute, works out which scripts in `cron/` are due, and requires them into its own process. Adding a job is a new script in `cron/` plus a line in the job table at the top of the dispatcher — `'every' => n` for interval jobs, `'daily_at' => 'HH:MM'` for daily ones. The crontab never changes again.
|
||||
`cron/cron.php` is a dispatcher. It wakes every minute, works out which scripts in `cron/` are due, and requires them into its own process. Adding a job is a new script in `cron/` plus an entry in `includes/cron_jobs.php`. The crontab never changes again.
|
||||
|
||||
That registry is the only thing that decides **which** scripts can run, and the schedule in it is only a default: it seeds the job's `cron_jobs` row the first time the dispatcher meets the job, and from then on the row is what runs, because Settings > Cron writes to it. The database therefore holds **when and whether**, never **what** — a row naming a script that is not in the registry is ignored, so nothing that reaches the database can point the dispatcher at an arbitrary file. Keep it that way.
|
||||
|
||||
Run Now in the admin UI does not execute anything in the web request: these scripts are CLI-only and some take minutes, so the button sets `cron_job_run_now` and the next dispatch picks it up, through the same lock and claim as a scheduled run.
|
||||
|
||||
Due-ness is recorded in the `cron_jobs` table rather than matched against the clock, so a job whose minute was missed — machine down, previous run still going — runs at the next opportunity instead of being skipped for the day. A job is claimed *before* it runs, not after: a run that dies half way through is not repeated, which matters because `nightly_tasks.php` generates invoices and charges cards. Each job is also locked individually for the length of its own run (`includes/cron_lock.php`), so a long or hung job holds up only itself — the next minute's dispatch picks up everything else in a second process.
|
||||
|
||||
|
||||
191
admin/cron.php
Normal file
191
admin/cron.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
require_once "includes/inc_all_admin.php";
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/includes/cron_jobs.php';
|
||||
|
||||
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_enable_cron, config_cron_last_dispatch_at FROM settings WHERE company_id = 1"));
|
||||
|
||||
$config_enable_cron = intval($row['config_enable_cron']);
|
||||
$cron_last_dispatch_at = $row['config_cron_last_dispatch_at'];
|
||||
|
||||
// The dispatcher writes its heartbeat before it runs anything, so anything older than a few
|
||||
// minutes means the crontab entry itself is missing or failing - a different problem from a
|
||||
// job that is disabled or erroring, and the one people spend the longest not finding.
|
||||
$cron_is_running = $cron_last_dispatch_at !== null && (time() - strtotime($cron_last_dispatch_at)) < 300;
|
||||
|
||||
$cron_command = "* * * * * php " . dirname(__DIR__) . "/cron/cron.php >/dev/null";
|
||||
|
||||
// Registry order is dispatch order, so the table reads the way the cycle runs
|
||||
$cron_jobs = [];
|
||||
foreach (cronJobRegistry() as $job) {
|
||||
$cron_jobs[$job['name']] = $job;
|
||||
$cron_jobs[$job['name']]['row'] = null;
|
||||
}
|
||||
|
||||
$sql = mysqli_query($mysqli, "SELECT * FROM cron_jobs");
|
||||
while ($job_row = mysqli_fetch_assoc($sql)) {
|
||||
if (isset($cron_jobs[$job_row['cron_job_name']])) {
|
||||
$cron_jobs[$job_row['cron_job_name']]['row'] = $job_row;
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
<div class="card card-dark">
|
||||
<div class="card-header py-3">
|
||||
<h3 class="card-title"><i class="fas fa-fw fa-clock mr-2"></i>Cron</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<?php if (!$cron_is_running) { ?>
|
||||
<div class="alert alert-danger">
|
||||
<h5><i class="fas fa-fw fa-exclamation-triangle mr-2"></i>Cron is not running</h5>
|
||||
ITFlow last heard from cron <strong><?= escapeHtml(strtolower(cronJobTimeAgo($cron_last_dispatch_at))) ?></strong>.
|
||||
Nothing below will run - no mail is being sent, no email is being turned into tickets, and invoices are not being generated.
|
||||
Add this line to the crontab of the user that owns the ITFlow files:
|
||||
<pre class="bg-dark text-white p-2 mt-2 mb-0"><?= escapeHtml($cron_command) ?></pre>
|
||||
</div>
|
||||
<?php } else { ?>
|
||||
<div class="alert alert-success">
|
||||
<i class="fas fa-fw fa-check mr-2"></i>Cron last checked in <strong><?= escapeHtml(strtolower(cronJobTimeAgo($cron_last_dispatch_at))) ?></strong>.
|
||||
<span class="text-muted ml-2"><?= escapeHtml($cron_command) ?></span>
|
||||
</div>
|
||||
<?php } ?>
|
||||
|
||||
<?php if ($config_enable_cron == 0) { ?>
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-fw fa-exclamation-circle mr-2"></i>Cron is switched off in
|
||||
<a href="settings_notification.php">Settings > Notifications</a>. The dispatcher is running, but most jobs
|
||||
stop themselves immediately while this is off.
|
||||
</div>
|
||||
<?php } ?>
|
||||
|
||||
<div class="table-responsive-sm">
|
||||
<table class="table table-borderless table-hover">
|
||||
<thead class="text-secondary">
|
||||
<tr>
|
||||
<th>Job</th>
|
||||
<th>Schedule</th>
|
||||
<th>Last Run</th>
|
||||
<th>Duration</th>
|
||||
<th>Status</th>
|
||||
<th>Next Run</th>
|
||||
<th class="text-center">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($cron_jobs as $job) {
|
||||
|
||||
$job_row = $job['row'];
|
||||
|
||||
// A job the dispatcher has not met yet has no row. It gets one on the next
|
||||
// pass, seeded from the schedule in the registry, so show that meanwhile.
|
||||
$cron_job_id = $job_row ? intval($job_row['cron_job_id']) : 0;
|
||||
$enabled = $job_row ? intval($job_row['cron_job_enabled']) : 1;
|
||||
$schedule = $job_row ? $job_row['cron_job_schedule'] : $job['schedule'];
|
||||
$interval_minutes = $job_row ? intval($job_row['cron_job_interval_minutes']) : intval($job['interval_minutes'] ?? 1);
|
||||
$daily_at = $job_row ? $job_row['cron_job_daily_at'] : ($job['daily_at'] ?? null);
|
||||
$run_now = $job_row ? intval($job_row['cron_job_run_now']) : 0;
|
||||
$last_run_at = $job_row ? $job_row['cron_job_last_run_at'] : null;
|
||||
$last_duration = $job_row ? $job_row['cron_job_last_duration'] : null;
|
||||
$last_status = $job_row ? $job_row['cron_job_last_status'] : null;
|
||||
$last_error = $job_row ? $job_row['cron_job_last_error'] : null;
|
||||
$last_error_at = $job_row ? $job_row['cron_job_last_error_at'] : null;
|
||||
|
||||
$next_run = $job_row ? cronJobNextRun($job_row) : null;
|
||||
|
||||
if ($run_now) {
|
||||
$status_badge = '<span class="badge badge-warning">Queued</span>';
|
||||
} elseif ($last_status === 'Running') {
|
||||
$status_badge = '<span class="badge badge-info">Running</span>';
|
||||
} elseif ($last_status === 'Completed') {
|
||||
$status_badge = '<span class="badge badge-success">Completed</span>';
|
||||
} elseif ($last_status === 'Failed') {
|
||||
$status_badge = '<span class="badge badge-danger">Failed</span>';
|
||||
} elseif ($last_status !== null) {
|
||||
$status_badge = '<span class="badge badge-secondary">Stopped</span>';
|
||||
} else {
|
||||
$status_badge = '<span class="badge badge-light">Never run</span>';
|
||||
}
|
||||
|
||||
?>
|
||||
<tr class="<?= $enabled ? '' : 'text-muted' ?>">
|
||||
<td>
|
||||
<strong><?= escapeHtml($job['label']) ?></strong>
|
||||
<?php if (!$enabled) { ?><span class="badge badge-secondary ml-1">Disabled</span><?php } ?>
|
||||
<br><small class="text-secondary"><?= escapeHtml($job['description']) ?></small>
|
||||
<br><small class="text-muted"><code>cron/<?= escapeHtml($job['script']) ?></code></small>
|
||||
</td>
|
||||
<td><?= escapeHtml(cronJobScheduleDescription($schedule, $interval_minutes, $daily_at)) ?></td>
|
||||
<td>
|
||||
<?= escapeHtml(cronJobTimeAgo($last_run_at)) ?>
|
||||
<?php if ($last_run_at) { ?><br><small class="text-muted"><?= escapeHtml(date('M j, g:i A', strtotime($last_run_at))) ?></small><?php } ?>
|
||||
</td>
|
||||
<td><?= $last_duration === null ? '-' : escapeHtml($last_duration) . 's' ?></td>
|
||||
<td>
|
||||
<?= $status_badge ?>
|
||||
<?php if ($last_status !== null && $last_status !== 'Running' && strlen($last_status) > 9) { ?>
|
||||
<br><small class="text-muted"><?= escapeHtml($last_status) ?></small>
|
||||
<?php } ?>
|
||||
</td>
|
||||
<td><?= $next_run === null ? '-' : escapeHtml(cronJobTimeAgo($next_run)) ?></td>
|
||||
<td class="text-center">
|
||||
<div class="dropdown dropleft text-center">
|
||||
<button class="btn btn-secondary btn-sm" type="button" data-toggle="dropdown">
|
||||
<i class="fas fa-ellipsis-v"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<a class="dropdown-item <?= $cron_job_id === 0 ? 'disabled' : '' ?>" href="post.php?run_cron_job=<?= $cron_job_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
|
||||
<i class="fas fa-fw fa-play mr-2"></i>Run Now
|
||||
</a>
|
||||
<button class="dropdown-item ajax-modal <?= $cron_job_id === 0 ? 'disabled' : '' ?>" type="button" data-toggle="ajax-modal"
|
||||
data-modal-url="modals/cron/cron_edit.php?id=<?= $cron_job_id ?>">
|
||||
<i class="fas fa-fw fa-edit mr-2"></i>Edit Schedule
|
||||
</button>
|
||||
<?php if ($enabled) { ?>
|
||||
<a class="dropdown-item text-danger" href="post.php?disable_cron_job=<?= $cron_job_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
|
||||
<i class="fas fa-fw fa-pause mr-2"></i>Disable
|
||||
</a>
|
||||
<?php } else { ?>
|
||||
<a class="dropdown-item text-success" href="post.php?enable_cron_job=<?= $cron_job_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
|
||||
<i class="fas fa-fw fa-play-circle mr-2"></i>Enable
|
||||
</a>
|
||||
<?php } ?>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php if (!empty($last_error)) { ?>
|
||||
<tr>
|
||||
<td colspan="7" class="pt-0">
|
||||
<div class="alert alert-danger mb-0 py-2">
|
||||
<div class="float-right">
|
||||
<a class="text-danger" href="post.php?clear_cron_error=<?= $cron_job_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>" title="Dismiss">
|
||||
<i class="fas fa-fw fa-times"></i>
|
||||
</a>
|
||||
</div>
|
||||
<strong><i class="fas fa-fw fa-exclamation-triangle mr-2"></i>Last error</strong>
|
||||
<small class="text-muted ml-2"><?= escapeHtml(cronJobTimeAgo($last_error_at)) ?></small>
|
||||
<div class="mt-1"><small><?= escapeHtml($last_error) ?></small></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
<?php } ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="text-muted mb-0">
|
||||
<small>
|
||||
<i class="fas fa-fw fa-info-circle mr-1"></i>Run Now does not start the job in your browser - it asks the
|
||||
dispatcher to pick it up on its next pass, so a job starts within a minute and still runs on the command
|
||||
line with the same locking as a scheduled run. Detailed per-job output is in
|
||||
<a href="app_logs.php">App Logs</a>.
|
||||
</small>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require_once $_SERVER['DOCUMENT_ROOT'] . "/includes/footer.php"; ?>
|
||||
32
admin/database_updates/2.6.1.php
Normal file
32
admin/database_updates/2.6.1.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* ITFlow - Database update to version 2.6.1 (from 2.6.0)
|
||||
* Included by admin/database_updates.php - do not access directly
|
||||
*/
|
||||
|
||||
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
|
||||
|
||||
// The cron dispatcher's schedule moves out of code and into the database so it can be
|
||||
// managed from Settings > Cron. The registry in includes/cron_jobs.php still decides
|
||||
// which scripts exist and seeds these columns the first time it meets a job; from then
|
||||
// on the row is what runs. Nothing here can name a script - a row whose job is not in
|
||||
// the registry is ignored.
|
||||
mysqli_query($mysqli, "ALTER TABLE `cron_jobs`
|
||||
ADD COLUMN `cron_job_enabled` tinyint(1) NOT NULL DEFAULT 1 AFTER `cron_job_name`,
|
||||
ADD COLUMN `cron_job_schedule` varchar(200) NOT NULL DEFAULT 'Interval' AFTER `cron_job_enabled`,
|
||||
ADD COLUMN `cron_job_interval_minutes` int(11) NOT NULL DEFAULT 1 AFTER `cron_job_schedule`,
|
||||
ADD COLUMN `cron_job_daily_at` time DEFAULT NULL AFTER `cron_job_interval_minutes`,
|
||||
ADD COLUMN `cron_job_run_now` tinyint(1) NOT NULL DEFAULT 0 AFTER `cron_job_daily_at`");
|
||||
|
||||
// Duration is here to make a job that is quietly getting slower visible before it starts
|
||||
// overrunning its own interval.
|
||||
mysqli_query($mysqli, "ALTER TABLE `cron_jobs`
|
||||
ADD COLUMN `cron_job_last_duration` decimal(10,2) DEFAULT NULL AFTER `cron_job_last_finished_at`,
|
||||
ADD COLUMN `cron_job_last_error` text DEFAULT NULL AFTER `cron_job_last_status`,
|
||||
ADD COLUMN `cron_job_last_error_at` datetime DEFAULT NULL AFTER `cron_job_last_error`");
|
||||
|
||||
// Written by the dispatcher every minute before it runs anything, so the admin page can
|
||||
// tell "no job happened to be due" apart from "the crontab entry is missing".
|
||||
mysqli_query($mysqli, "ALTER TABLE `settings`
|
||||
ADD COLUMN `config_cron_last_dispatch_at` datetime DEFAULT NULL");
|
||||
@@ -161,6 +161,12 @@
|
||||
|
||||
<li class="nav-header">MAINTENANCE</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a href="/admin/cron.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'cron.php' ? 'active' : '') ?>">
|
||||
<i class="nav-icon fas fa-clock"></i>
|
||||
<p>Cron</p>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="/admin/mail_queue.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'mail_queue.php' ? 'active' : '') ?>">
|
||||
<i class="nav-icon fas fa-inbox"></i>
|
||||
|
||||
103
admin/modals/cron/cron_edit.php
Normal file
103
admin/modals/cron/cron_edit.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
require_once '../../../includes/modal_header.php';
|
||||
require_once '../../../includes/cron_jobs.php';
|
||||
|
||||
$cron_job_id = intval($_GET['id']);
|
||||
|
||||
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT * FROM cron_jobs WHERE cron_job_id = $cron_job_id LIMIT 1"));
|
||||
|
||||
$registry = cronJobRegistryByName();
|
||||
$job = $registry[$row['cron_job_name']] ?? null;
|
||||
|
||||
$cron_job_name = escapeHtml($row['cron_job_name']);
|
||||
$cron_job_label = escapeHtml($job['label'] ?? $row['cron_job_name']);
|
||||
$cron_job_enabled = intval($row['cron_job_enabled']);
|
||||
$cron_job_schedule = escapeHtml($row['cron_job_schedule']);
|
||||
$cron_job_interval_minutes = intval($row['cron_job_interval_minutes']);
|
||||
$cron_job_daily_at = escapeHtml(substr((string)$row['cron_job_daily_at'], 0, 5));
|
||||
|
||||
if (empty($cron_job_daily_at)) {
|
||||
$cron_job_daily_at = '03:00';
|
||||
}
|
||||
|
||||
// Generate the HTML form content using output buffering.
|
||||
ob_start();
|
||||
?>
|
||||
<div class="modal-header bg-dark">
|
||||
<h5 class="modal-title"><i class="fa fa-fw fa-clock mr-2"></i>Editing: <strong><?= $cron_job_label ?></strong></h5>
|
||||
<button type="button" class="close text-white" data-dismiss="modal">
|
||||
<span>×</span>
|
||||
</button>
|
||||
</div>
|
||||
<form action="post.php" method="post" autocomplete="off">
|
||||
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
|
||||
<input type="hidden" name="cron_job_id" value="<?= $cron_job_id ?>">
|
||||
<div class="modal-body">
|
||||
|
||||
<div class="form-group">
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input" name="enabled" value="1" id="cronJobEnabledSwitch" <?= $cron_job_enabled == 1 ? 'checked' : '' ?>>
|
||||
<label class="custom-control-label" for="cronJobEnabledSwitch">Enabled</label>
|
||||
</div>
|
||||
<small class="text-muted">A disabled job never runs on its schedule, but Run Now still works.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Schedule</label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-calendar"></i></span>
|
||||
</div>
|
||||
<select class="form-control" name="schedule" id="cronJobSchedule">
|
||||
<option value="Interval" <?= $cron_job_schedule === 'Interval' ? 'selected' : '' ?>>Every so many minutes</option>
|
||||
<option value="Daily" <?= $cron_job_schedule === 'Daily' ? 'selected' : '' ?>>Once a day</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="cronJobIntervalGroup">
|
||||
<label>Run every</label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-redo"></i></span>
|
||||
</div>
|
||||
<input type="number" class="form-control" name="interval_minutes" value="<?= $cron_job_interval_minutes ?>" min="1" max="1440">
|
||||
<div class="input-group-append">
|
||||
<span class="input-group-text">minutes</span>
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted">Cron wakes once a minute, so 1 is as often as anything can run.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="cronJobDailyGroup">
|
||||
<label>Run at</label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-clock"></i></span>
|
||||
</div>
|
||||
<input type="time" class="form-control" name="daily_at" value="<?= $cron_job_daily_at ?>">
|
||||
</div>
|
||||
<small class="text-muted">Your ITFlow timezone. A run missed because the server was off happens at the next opportunity instead of waiting a day.</small>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" name="edit_cron_job" class="btn btn-primary text-bold"><i class="fa fa-check mr-2"></i>Save</button>
|
||||
<button type="button" class="btn btn-light" data-dismiss="modal"><i class="fa fa-times mr-2"></i>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function cronJobToggleScheduleFields() {
|
||||
var daily = document.getElementById('cronJobSchedule').value === 'Daily';
|
||||
document.getElementById('cronJobIntervalGroup').hidden = daily;
|
||||
document.getElementById('cronJobDailyGroup').hidden = !daily;
|
||||
}
|
||||
document.getElementById('cronJobSchedule').addEventListener('change', cronJobToggleScheduleFields);
|
||||
cronJobToggleScheduleFields();
|
||||
</script>
|
||||
|
||||
<?php
|
||||
|
||||
require_once '../../../includes/modal_footer.php';
|
||||
130
admin/post/cron.php
Normal file
130
admin/post/cron.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
|
||||
|
||||
/*
|
||||
* Settings > Cron. Everything here identifies a job by its row, and every row is checked
|
||||
* against the registry in includes/cron_jobs.php before anything is written - the database
|
||||
* decides when and whether a job runs, never which file the dispatcher executes.
|
||||
*/
|
||||
|
||||
if (isset($_POST['edit_cron_job'])) {
|
||||
|
||||
validateCSRFToken();
|
||||
|
||||
require_once "../includes/cron_jobs.php";
|
||||
|
||||
$cron_job_id = intval($_POST['cron_job_id']);
|
||||
|
||||
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT cron_job_name FROM cron_jobs WHERE cron_job_id = $cron_job_id LIMIT 1"));
|
||||
$registry = cronJobRegistryByName();
|
||||
|
||||
if (!$row || !isset($registry[$row['cron_job_name']])) {
|
||||
flashAlert("That cron job is not part of this version of ITFlow.", 'error');
|
||||
redirect();
|
||||
}
|
||||
|
||||
$cron_job_name = escapeSql($row['cron_job_name']);
|
||||
$enabled = isset($_POST['enabled']) ? 1 : 0;
|
||||
$schedule = $_POST['schedule'] === 'Daily' ? 'Daily' : 'Interval';
|
||||
|
||||
// A job cannot be asked to run more than once a minute (the dispatcher only wakes that
|
||||
// often) and anything over a day belongs on the daily schedule instead.
|
||||
$interval_minutes = min(1440, max(1, intval($_POST['interval_minutes'])));
|
||||
|
||||
// Time inputs hand back HH:MM; anything else is discarded rather than stored half-parsed
|
||||
$daily_at = 'NULL';
|
||||
if ($schedule === 'Daily') {
|
||||
$submitted = trim($_POST['daily_at']);
|
||||
if (!preg_match('/^([01][0-9]|2[0-3]):[0-5][0-9]$/', $submitted)) {
|
||||
flashAlert("Enter the daily run time as a 24-hour time, for example 03:00.", 'error');
|
||||
redirect();
|
||||
}
|
||||
$daily_at = "'" . escapeSql($submitted) . ":00'";
|
||||
}
|
||||
|
||||
mysqli_query($mysqli, "UPDATE cron_jobs SET
|
||||
cron_job_enabled = $enabled,
|
||||
cron_job_schedule = '$schedule',
|
||||
cron_job_interval_minutes = $interval_minutes,
|
||||
cron_job_daily_at = $daily_at
|
||||
WHERE cron_job_id = $cron_job_id");
|
||||
|
||||
logAudit("Cron", "Edit", "$session_name edited the schedule for cron job $cron_job_name", 0, $cron_job_id);
|
||||
|
||||
flashAlert("Cron job schedule updated.");
|
||||
|
||||
redirect();
|
||||
|
||||
}
|
||||
|
||||
if (isset($_GET['run_cron_job'])) {
|
||||
|
||||
validateCSRFToken();
|
||||
|
||||
require_once "../includes/cron_jobs.php";
|
||||
|
||||
$cron_job_id = intval($_GET['run_cron_job']);
|
||||
|
||||
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT cron_job_name FROM cron_jobs WHERE cron_job_id = $cron_job_id LIMIT 1"));
|
||||
$registry = cronJobRegistryByName();
|
||||
|
||||
if (!$row || !isset($registry[$row['cron_job_name']])) {
|
||||
flashAlert("That cron job is not part of this version of ITFlow.", 'error');
|
||||
redirect();
|
||||
}
|
||||
|
||||
$cron_job_name = escapeSql($row['cron_job_name']);
|
||||
$cron_job_label = $registry[$row['cron_job_name']]['label'];
|
||||
|
||||
// The job is not run here. These scripts are written for the command line and some of
|
||||
// them take minutes, so the request is left for the dispatcher to pick up on its next
|
||||
// pass, which also means it goes through the same lock and claim as a scheduled run.
|
||||
mysqli_query($mysqli, "UPDATE cron_jobs SET cron_job_run_now = 1 WHERE cron_job_id = $cron_job_id");
|
||||
|
||||
logAudit("Cron", "Run", "$session_name requested an immediate run of cron job $cron_job_name", 0, $cron_job_id);
|
||||
|
||||
flashAlert("$cron_job_label is queued and will start within a minute.");
|
||||
|
||||
redirect();
|
||||
|
||||
}
|
||||
|
||||
if (isset($_GET['enable_cron_job']) || isset($_GET['disable_cron_job'])) {
|
||||
|
||||
validateCSRFToken();
|
||||
|
||||
$enabled = isset($_GET['enable_cron_job']) ? 1 : 0;
|
||||
$cron_job_id = intval($_GET[$enabled ? 'enable_cron_job' : 'disable_cron_job']);
|
||||
|
||||
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT cron_job_name FROM cron_jobs WHERE cron_job_id = $cron_job_id LIMIT 1"));
|
||||
|
||||
if (!$row) {
|
||||
redirect();
|
||||
}
|
||||
|
||||
$cron_job_name = escapeSql($row['cron_job_name']);
|
||||
|
||||
mysqli_query($mysqli, "UPDATE cron_jobs SET cron_job_enabled = $enabled WHERE cron_job_id = $cron_job_id");
|
||||
|
||||
logAudit("Cron", "Edit", "$session_name " . ($enabled ? 'enabled' : 'disabled') . " cron job $cron_job_name", 0, $cron_job_id);
|
||||
|
||||
flashAlert("Cron job " . ($enabled ? 'enabled' : 'disabled') . ".", $enabled ? 'success' : 'error');
|
||||
|
||||
redirect();
|
||||
|
||||
}
|
||||
|
||||
if (isset($_GET['clear_cron_error'])) {
|
||||
|
||||
validateCSRFToken();
|
||||
|
||||
$cron_job_id = intval($_GET['clear_cron_error']);
|
||||
|
||||
mysqli_query($mysqli, "UPDATE cron_jobs SET cron_job_last_error = NULL, cron_job_last_error_at = NULL WHERE cron_job_id = $cron_job_id");
|
||||
|
||||
flashAlert("Error cleared.");
|
||||
|
||||
redirect();
|
||||
|
||||
}
|
||||
129
cron/cron.php
129
cron/cron.php
@@ -7,8 +7,9 @@
|
||||
*
|
||||
* * * * * * php /path/to/itflow/cron/cron.php >/dev/null
|
||||
*
|
||||
* It wakes once a minute, works out which jobs are due, and runs them. Adding a job is a
|
||||
* new script in cron/ and a line in the table below - the crontab never has to change again.
|
||||
* It wakes once a minute, works out which jobs are due, and runs them. The jobs themselves
|
||||
* are listed in includes/cron_jobs.php; when and whether each one runs is held in the
|
||||
* cron_jobs table and edited from Settings > Cron.
|
||||
*
|
||||
* WHAT THE JOBS INHERIT
|
||||
*
|
||||
@@ -54,34 +55,13 @@ require_once "../config.php";
|
||||
// Set Timezone
|
||||
require_once "../includes/inc_set_timezone.php";
|
||||
require_once "../functions.php";
|
||||
|
||||
/*
|
||||
* The jobs, in the order they run.
|
||||
*
|
||||
* 'every' => n run every n minutes
|
||||
* 'daily_at' => 'HH:MM' run once a day, at or after this time
|
||||
*
|
||||
* Order matters: the every-minute jobs come first so a nightly run cannot delay them, and
|
||||
* mail_queue comes before the jobs that queue mail so nothing sits in the queue for a
|
||||
* minute longer than it has to.
|
||||
*
|
||||
* Each job still checks its own settings (cron enabled, email parsing enabled, and so on)
|
||||
* and stops itself when it has nothing to do, so a disabled feature costs a require and a
|
||||
* settings read.
|
||||
*/
|
||||
$cron_dispatch_jobs = [
|
||||
['name' => 'mail_queue', 'script' => 'mail_queue.php', 'every' => 1],
|
||||
['name' => 'ticket_email_parser', 'script' => 'ticket_email_parser.php', 'every' => 1],
|
||||
['name' => 'ticket_sla', 'script' => 'ticket_sla.php', 'every' => 1],
|
||||
['name' => 'domain_refresher', 'script' => 'domain_refresher.php', 'every' => 5],
|
||||
['name' => 'nightly_tasks', 'script' => 'nightly_tasks.php', 'daily_at' => '03:00'],
|
||||
['name' => 'certificate_refresher', 'script' => 'certificate_refresher.php', 'daily_at' => '03:30'],
|
||||
];
|
||||
require_once "../includes/cron_jobs.php";
|
||||
|
||||
/*
|
||||
* Claim a job if it is due. The UPDATE is the claim: two dispatchers racing for the same
|
||||
* job both run it against the same row and the loser matches nothing, which also holds
|
||||
* across two web servers sharing one database, where the file lock would not.
|
||||
* across two web servers sharing one database, where the file lock would not. The same
|
||||
* statement consumes a Run Now request, so a button press can only ever produce one run.
|
||||
*
|
||||
* Every comparison is made against PHP's clock, not the database's, so the schedule follows
|
||||
* the timezone ITFlow is configured for however the database server is set up.
|
||||
@@ -91,68 +71,111 @@ function cronJobClaim($mysqli, array $job): bool
|
||||
$name = escapeSql($job['name']);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
// Register the job the first time it is seen - adding a job needs no migration
|
||||
mysqli_query($mysqli, "INSERT IGNORE INTO cron_jobs SET cron_job_name = '$name'");
|
||||
// Register the job the first time it is seen, seeded with the schedule it ships with.
|
||||
// From here on the row is what runs - Settings > Cron writes to it.
|
||||
$default_schedule = escapeSql($job['schedule']);
|
||||
$default_interval = intval($job['interval_minutes'] ?? 1);
|
||||
$default_daily_at = isset($job['daily_at']) ? "'" . escapeSql($job['daily_at']) . ":00'" : 'NULL';
|
||||
|
||||
if (isset($job['daily_at'])) {
|
||||
// Due once today's scheduled time has passed, unless we have already run since it
|
||||
$threshold = date('Y-m-d') . ' ' . $job['daily_at'] . ':00';
|
||||
if ($now < $threshold) {
|
||||
return false;
|
||||
mysqli_query($mysqli, "INSERT IGNORE INTO cron_jobs SET
|
||||
cron_job_name = '$name',
|
||||
cron_job_schedule = '$default_schedule',
|
||||
cron_job_interval_minutes = $default_interval,
|
||||
cron_job_daily_at = $default_daily_at");
|
||||
|
||||
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT * FROM cron_jobs WHERE cron_job_name = '$name' LIMIT 1"));
|
||||
|
||||
if (!$row) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A Run Now request runs the job whatever its schedule says, and whether or not it is
|
||||
// enabled - turning the schedule off and running it by hand is a legitimate way to work.
|
||||
$due_clause = "cron_job_run_now = 1";
|
||||
|
||||
if (!empty($row['cron_job_enabled'])) {
|
||||
|
||||
if ($row['cron_job_schedule'] === 'Daily') {
|
||||
// Due once today's scheduled time has passed, unless we have already run since it
|
||||
$threshold = date('Y-m-d') . ' ' . substr((string)$row['cron_job_daily_at'], 0, 5) . ':00';
|
||||
$scheduled = ($now >= $threshold);
|
||||
} else {
|
||||
// Interval jobs get 30 seconds of slack. cron fires on the minute but a run can
|
||||
// start a second or two late, and an exact n-minute comparison would then find the
|
||||
// job 'not due yet' and skip every other cycle.
|
||||
$interval = max(1, intval($row['cron_job_interval_minutes']));
|
||||
$threshold = date('Y-m-d H:i:s', time() - (($interval * 60) - 30));
|
||||
$scheduled = true;
|
||||
}
|
||||
|
||||
if ($scheduled) {
|
||||
$threshold = escapeSql($threshold);
|
||||
$due_clause .= " OR (cron_job_enabled = 1 AND (cron_job_last_run_at IS NULL OR cron_job_last_run_at < '$threshold'))";
|
||||
}
|
||||
} else {
|
||||
// Interval jobs get 30 seconds of slack. cron fires on the minute but a run can
|
||||
// start a second or two late, and an exact n-minute comparison would then find the
|
||||
// job 'not due yet' and skip every other cycle.
|
||||
$every = max(1, intval($job['every'] ?? 1));
|
||||
$threshold = date('Y-m-d H:i:s', time() - (($every * 60) - 30));
|
||||
}
|
||||
|
||||
mysqli_query($mysqli, "UPDATE cron_jobs SET
|
||||
cron_job_last_run_at = '$now',
|
||||
cron_job_last_status = 'Running'
|
||||
cron_job_last_status = 'Running',
|
||||
cron_job_run_now = 0
|
||||
WHERE cron_job_name = '$name'
|
||||
AND (cron_job_last_run_at IS NULL OR cron_job_last_run_at < '$threshold')");
|
||||
AND ($due_clause)");
|
||||
|
||||
return mysqli_affected_rows($mysqli) === 1;
|
||||
}
|
||||
|
||||
/*
|
||||
* Record how a job ended. Only ever cosmetic - nothing schedules off the result - but it is
|
||||
* the difference between "cron is broken" and knowing which job broke and when.
|
||||
* Record how a job ended. The status is the outcome of the run that just happened; the error
|
||||
* is sticky and survives later successes, because the run that failed is usually long gone by
|
||||
* the time anyone goes looking. Settings > Cron clears it.
|
||||
*/
|
||||
function cronJobFinished($mysqli, string $job_name, string $status): void
|
||||
function cronJobFinished($mysqli, string $job_name, string $status, ?float $duration = null, ?string $error = null): void
|
||||
{
|
||||
$name = escapeSql($job_name);
|
||||
$status = escapeSql(substr($status, 0, 200));
|
||||
$finished_at = date('Y-m-d H:i:s');
|
||||
$duration_sql = $duration === null ? 'NULL' : "'" . number_format($duration, 2, '.', '') . "'";
|
||||
|
||||
$error_sql = '';
|
||||
if ($error !== null) {
|
||||
$error_text = escapeSql(substr($error, 0, 1000));
|
||||
$error_sql = ", cron_job_last_error = '$error_text', cron_job_last_error_at = '$finished_at'";
|
||||
}
|
||||
|
||||
mysqli_query($mysqli, "UPDATE cron_jobs SET
|
||||
cron_job_last_finished_at = '$finished_at',
|
||||
cron_job_last_status = '$status'
|
||||
cron_job_last_status = '$status',
|
||||
cron_job_last_duration = $duration_sql
|
||||
$error_sql
|
||||
WHERE cron_job_name = '$name'");
|
||||
}
|
||||
|
||||
// Proof the crontab is firing, recorded before any job runs. Settings > Cron reads it to tell
|
||||
// "no job was due" apart from "nothing has run this since the server was rebuilt".
|
||||
mysqli_query($mysqli, "UPDATE settings SET config_cron_last_dispatch_at = '" . date('Y-m-d H:i:s') . "' WHERE company_id = 1");
|
||||
|
||||
// A fatal error inside a job cannot be caught, and it takes the rest of the cycle with it.
|
||||
// Recording which job was running at the time is the only trace of that left behind.
|
||||
$cron_dispatch_running = null;
|
||||
register_shutdown_function(function () use (&$cron_dispatch_running, $mysqli) {
|
||||
$cron_dispatch_started = null;
|
||||
register_shutdown_function(function () use (&$cron_dispatch_running, &$cron_dispatch_started, $mysqli) {
|
||||
if ($cron_dispatch_running === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$error = error_get_last();
|
||||
$reason = $error['message'] ?? 'ended unexpectedly';
|
||||
$duration = $cron_dispatch_started === null ? null : microtime(true) - $cron_dispatch_started;
|
||||
|
||||
cronJobFinished($mysqli, $cron_dispatch_running, "Failed: $reason");
|
||||
cronJobFinished($mysqli, $cron_dispatch_running, 'Failed', $duration, $reason);
|
||||
});
|
||||
|
||||
foreach ($cron_dispatch_jobs as $cron_dispatch_job) {
|
||||
foreach (cronJobRegistry() as $cron_dispatch_job) {
|
||||
|
||||
$cron_dispatch_path = realpath(__DIR__ . '/' . $cron_dispatch_job['script']);
|
||||
|
||||
if ($cron_dispatch_path === false) {
|
||||
// A job listed above with no script behind it is a mistake worth hearing about
|
||||
// A job listed in the registry with no script behind it is a mistake worth hearing about
|
||||
echo "Cron: job '{$cron_dispatch_job['name']}' points at {$cron_dispatch_job['script']}, which does not exist.\n";
|
||||
continue;
|
||||
}
|
||||
@@ -170,21 +193,23 @@ foreach ($cron_dispatch_jobs as $cron_dispatch_job) {
|
||||
}
|
||||
|
||||
$cron_dispatch_running = $cron_dispatch_job['name'];
|
||||
$cron_dispatch_started = microtime(true);
|
||||
|
||||
try {
|
||||
require_once $cron_dispatch_path;
|
||||
cronJobFinished($mysqli, $cron_dispatch_job['name'], 'Completed');
|
||||
cronJobFinished($mysqli, $cron_dispatch_job['name'], 'Completed', microtime(true) - $cron_dispatch_started);
|
||||
} catch (CronJobStopped $e) {
|
||||
// The job ended itself early - disabled in settings, nothing configured, nothing to do
|
||||
$reason = $e->getMessage();
|
||||
cronJobFinished($mysqli, $cron_dispatch_job['name'], $reason === '' ? 'Stopped' : "Stopped: $reason");
|
||||
cronJobFinished($mysqli, $cron_dispatch_job['name'], $reason === '' ? 'Stopped' : "Stopped: $reason", microtime(true) - $cron_dispatch_started);
|
||||
} catch (Throwable $e) {
|
||||
// One job throwing is not a reason to skip the rest of the cycle
|
||||
logApp("Cron", "error", "Cron job {$cron_dispatch_job['name']} failed: " . $e->getMessage());
|
||||
cronJobFinished($mysqli, $cron_dispatch_job['name'], 'Failed: ' . $e->getMessage());
|
||||
cronJobFinished($mysqli, $cron_dispatch_job['name'], 'Failed', microtime(true) - $cron_dispatch_started, $e->getMessage());
|
||||
}
|
||||
|
||||
$cron_dispatch_running = null;
|
||||
$cron_dispatch_started = null;
|
||||
|
||||
cronLockRelease($cron_dispatch_lock);
|
||||
}
|
||||
|
||||
9
db.sql
9
db.sql
@@ -960,9 +960,17 @@ DROP TABLE IF EXISTS `cron_jobs`;
|
||||
CREATE TABLE `cron_jobs` (
|
||||
`cron_job_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`cron_job_name` varchar(200) NOT NULL,
|
||||
`cron_job_enabled` tinyint(1) NOT NULL DEFAULT 1,
|
||||
`cron_job_schedule` varchar(200) NOT NULL DEFAULT 'Interval',
|
||||
`cron_job_interval_minutes` int(11) NOT NULL DEFAULT 1,
|
||||
`cron_job_daily_at` time DEFAULT NULL,
|
||||
`cron_job_run_now` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`cron_job_last_run_at` datetime DEFAULT NULL,
|
||||
`cron_job_last_finished_at` datetime DEFAULT NULL,
|
||||
`cron_job_last_duration` decimal(10,2) DEFAULT NULL,
|
||||
`cron_job_last_status` varchar(200) DEFAULT NULL,
|
||||
`cron_job_last_error` text DEFAULT NULL,
|
||||
`cron_job_last_error_at` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`cron_job_id`),
|
||||
UNIQUE KEY `cron_job_name` (`cron_job_name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
@@ -2240,6 +2248,7 @@ CREATE TABLE `settings` (
|
||||
`config_ticket_default_billable` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`config_ticket_timer_autostart` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`config_enable_cron` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`config_cron_last_dispatch_at` datetime DEFAULT NULL,
|
||||
`config_recurring_auto_send_invoice` tinyint(1) NOT NULL DEFAULT 1,
|
||||
`config_enable_alert_domain_expire` tinyint(1) NOT NULL DEFAULT 1,
|
||||
`config_send_invoice_reminders` tinyint(1) NOT NULL DEFAULT 1,
|
||||
|
||||
165
includes/cron_jobs.php
Normal file
165
includes/cron_jobs.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* ITFlow - The cron job registry
|
||||
*
|
||||
* The list of jobs cron/cron.php knows how to run, and the schedule each one ships with.
|
||||
* Adding a job is a script in cron/ and an entry here - the crontab never changes.
|
||||
*
|
||||
* Schedules here are DEFAULTS. They seed the job's row in the cron_jobs table the first
|
||||
* time the dispatcher sees it, and from then on the row is what runs: Settings > Cron
|
||||
* writes to it. Changing a default in this file therefore only affects installs that have
|
||||
* not met the job yet.
|
||||
*
|
||||
* This file is the only thing that decides which scripts can be run. The database holds
|
||||
* when and whether, never what - a row naming a script that is not listed here is ignored,
|
||||
* so nothing that reaches the database can point the dispatcher at an arbitrary file.
|
||||
*/
|
||||
|
||||
function cronJobRegistry(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'name' => 'mail_queue',
|
||||
'label' => 'Mail Queue',
|
||||
'script' => 'mail_queue.php',
|
||||
'description' => 'Sends everything ITFlow has queued - invoices, quotes, ticket replies, notifications.',
|
||||
'schedule' => 'Interval',
|
||||
'interval_minutes' => 1,
|
||||
],
|
||||
[
|
||||
'name' => 'ticket_email_parser',
|
||||
'label' => 'Ticket Email Parser',
|
||||
'script' => 'ticket_email_parser.php',
|
||||
'description' => 'Reads the support mailbox and turns incoming mail into tickets and replies.',
|
||||
'schedule' => 'Interval',
|
||||
'interval_minutes' => 1,
|
||||
],
|
||||
[
|
||||
'name' => 'ticket_sla',
|
||||
'label' => 'Ticket SLA Monitor',
|
||||
'script' => 'ticket_sla.php',
|
||||
'description' => 'Moves tickets through their SLA warning and breach stages and sends the alerts.',
|
||||
'schedule' => 'Interval',
|
||||
'interval_minutes' => 1,
|
||||
],
|
||||
[
|
||||
'name' => 'domain_refresher',
|
||||
'label' => 'Domain Refresher',
|
||||
'script' => 'domain_refresher.php',
|
||||
'description' => 'Refreshes WHOIS and DNS for the domain that was checked longest ago. One domain per run.',
|
||||
'schedule' => 'Interval',
|
||||
'interval_minutes' => 5,
|
||||
],
|
||||
[
|
||||
'name' => 'nightly_tasks',
|
||||
'label' => 'Nightly Tasks',
|
||||
'script' => 'nightly_tasks.php',
|
||||
'description' => 'The daily run: recurring invoices and tickets, overdue reminders, autopay, late fees, clean-up, update check.',
|
||||
'schedule' => 'Daily',
|
||||
'daily_at' => '03:00',
|
||||
],
|
||||
[
|
||||
'name' => 'certificate_refresher',
|
||||
'label' => 'Certificate Refresher',
|
||||
'script' => 'certificate_refresher.php',
|
||||
'description' => 'Re-reads the expiry date and issuer of every SSL certificate on file.',
|
||||
'schedule' => 'Daily',
|
||||
'daily_at' => '03:30',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/*
|
||||
* The registry keyed by job name, for looking up what a cron_jobs row belongs to.
|
||||
*/
|
||||
function cronJobRegistryByName(): array
|
||||
{
|
||||
$jobs = [];
|
||||
|
||||
foreach (cronJobRegistry() as $job) {
|
||||
$jobs[$job['name']] = $job;
|
||||
}
|
||||
|
||||
return $jobs;
|
||||
}
|
||||
|
||||
/*
|
||||
* How a schedule reads in the admin UI: "Every minute", "Every 5 minutes", "Daily at 03:00".
|
||||
*/
|
||||
function cronJobScheduleDescription(string $schedule, int $interval_minutes, ?string $daily_at): string
|
||||
{
|
||||
if ($schedule === 'Daily') {
|
||||
return 'Daily at ' . substr((string)$daily_at, 0, 5);
|
||||
}
|
||||
|
||||
if ($interval_minutes === 1) {
|
||||
return 'Every minute';
|
||||
}
|
||||
|
||||
if ($interval_minutes === 60) {
|
||||
return 'Hourly';
|
||||
}
|
||||
|
||||
return "Every $interval_minutes minutes";
|
||||
}
|
||||
|
||||
/*
|
||||
* When a job is next expected to run, or null when it is disabled or nothing is scheduled.
|
||||
* Interval jobs that are already overdue read as due now rather than as a time in the past.
|
||||
*/
|
||||
function cronJobNextRun(array $row): ?string
|
||||
{
|
||||
if (empty($row['cron_job_enabled'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$last_run = $row['cron_job_last_run_at'];
|
||||
|
||||
if ($row['cron_job_schedule'] === 'Daily') {
|
||||
$today = date('Y-m-d') . ' ' . substr((string)$row['cron_job_daily_at'], 0, 5) . ':00';
|
||||
|
||||
if ($last_run === null || $last_run < $today) {
|
||||
return $today <= date('Y-m-d H:i:s') ? date('Y-m-d H:i:s') : $today;
|
||||
}
|
||||
|
||||
return date('Y-m-d H:i:s', strtotime($today) + 86400);
|
||||
}
|
||||
|
||||
if ($last_run === null) {
|
||||
return date('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$next = strtotime($last_run) + (max(1, intval($row['cron_job_interval_minutes'])) * 60);
|
||||
|
||||
return date('Y-m-d H:i:s', max($next, time()));
|
||||
}
|
||||
|
||||
/*
|
||||
* "4 minutes ago" / "in 2 minutes" for the admin page. Anything older than a day reads as a
|
||||
* date, because "27,000 minutes ago" tells nobody anything.
|
||||
*/
|
||||
function cronJobTimeAgo(?string $datetime): string
|
||||
{
|
||||
if (empty($datetime)) {
|
||||
return 'Never';
|
||||
}
|
||||
|
||||
$seconds = time() - strtotime($datetime);
|
||||
$ahead = $seconds < 0;
|
||||
$seconds = abs($seconds);
|
||||
|
||||
if ($seconds < 60) {
|
||||
$text = 'less than a minute';
|
||||
} elseif ($seconds < 3600) {
|
||||
$minutes = floor($seconds / 60);
|
||||
$text = $minutes . ' minute' . ($minutes == 1 ? '' : 's');
|
||||
} elseif ($seconds < 86400) {
|
||||
$hours = floor($seconds / 3600);
|
||||
$text = $hours . ' hour' . ($hours == 1 ? '' : 's');
|
||||
} else {
|
||||
return date('M j, g:i A', strtotime($datetime));
|
||||
}
|
||||
|
||||
return $ahead ? "in $text" : "$text ago";
|
||||
}
|
||||
Reference in New Issue
Block a user