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

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();
}