This commit is contained in:
johnnyq
2026-07-30 16:44:56 -04:00
parent 38e8edea19
commit 119334ab71
10 changed files with 732 additions and 59 deletions

View File

@@ -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"

View File

@@ -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
View 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 &gt; 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"; ?>

View 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");

View File

@@ -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>

View 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>&times;</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
View 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();
}

View File

@@ -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
View File

@@ -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
View 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";
}