first commit

This commit is contained in:
bach
2026-01-16 14:41:07 +01:00
commit 73c2839c60
901 changed files with 137820 additions and 0 deletions
+577
View File
@@ -0,0 +1,577 @@
<?php
/**
* @package Grav\Common\Scheduler
* @author Originally based on jqCron by Arnaud Buathier <arnaud@arnapou.net> modified for Grav integration
* @copyright Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Scheduler;
/*
* Usage examples :
* ----------------
*
* $cron = new Cron('10-30/5 12 * * *');
*
* var_dump($cron->getMinutes());
* // array(5) {
* // [0]=> int(10)
* // [1]=> int(15)
* // [2]=> int(20)
* // [3]=> int(25)
* // [4]=> int(30)
* // }
*
* var_dump($cron->getText('fr'));
* // string(32) "Chaque jour à 12:10,15,20,25,30"
*
* var_dump($cron->getText('en'));
* // string(30) "Every day at 12:10,15,20,25,30"
*
* var_dump($cron->getType());
* // string(3) "day"
*
* var_dump($cron->getCronHours());
* // string(2) "12"
*
* var_dump($cron->matchExact(new \DateTime('2012-07-01 13:25:10')));
* // bool(false)
*
* var_dump($cron->matchExact(new \DateTime('2012-07-01 12:15:20')));
* // bool(true)
*
* var_dump($cron->matchWithMargin(new \DateTime('2012-07-01 12:32:50'), -3, 5));
* // bool(true)
*/
use DateInterval;
use DateTime;
use RuntimeException;
use function count;
use function in_array;
use function is_array;
use function is_string;
class Cron
{
public const TYPE_UNDEFINED = '';
public const TYPE_MINUTE = 'minute';
public const TYPE_HOUR = 'hour';
public const TYPE_DAY = 'day';
public const TYPE_WEEK = 'week';
public const TYPE_MONTH = 'month';
public const TYPE_YEAR = 'year';
/**
*
* @var array
*/
protected $texts = [
'fr' => [
'empty' => '-tout-',
'name_minute' => 'minute',
'name_hour' => 'heure',
'name_day' => 'jour',
'name_week' => 'semaine',
'name_month' => 'mois',
'name_year' => 'année',
'text_period' => 'Chaque %s',
'text_mins' => 'à %s minutes',
'text_time' => 'à %02s:%02s',
'text_dow' => 'le %s',
'text_month' => 'de %s',
'text_dom' => 'le %s',
'weekdays' => ['lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche'],
'months' => ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'],
],
'en' => [
'empty' => '-all-',
'name_minute' => 'minute',
'name_hour' => 'hour',
'name_day' => 'day',
'name_week' => 'week',
'name_month' => 'month',
'name_year' => 'year',
'text_period' => 'Every %s',
'text_mins' => 'at %s minutes past the hour',
'text_time' => 'at %02s:%02s',
'text_dow' => 'on %s',
'text_month' => 'of %s',
'text_dom' => 'on the %s',
'weekdays' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'],
'months' => ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december'],
],
];
/**
* min hour dom month dow
* @var string
*/
protected $cron = '';
/**
*
* @var array
*/
protected $minutes = [];
/**
*
* @var array
*/
protected $hours = [];
/**
*
* @var array
*/
protected $months = [];
/**
* 0-7 : sunday, monday, ... saturday, sunday
* @var array
*/
protected $dow = [];
/**
*
* @var array
*/
protected $dom = [];
/**
* @param string|null $cron
*/
public function __construct($cron = null)
{
if (null !== $cron) {
$this->setCron($cron);
}
}
/**
* @return string
*/
public function getCron()
{
return implode(' ', [
$this->getCronMinutes(),
$this->getCronHours(),
$this->getCronDaysOfMonth(),
$this->getCronMonths(),
$this->getCronDaysOfWeek(),
]);
}
/**
* @param string $lang 'fr' or 'en'
* @return string
*/
public function getText($lang)
{
// check lang
if (!isset($this->texts[$lang])) {
return $this->getCron();
}
$texts = $this->texts[$lang];
// check type
$type = $this->getType();
if ($type === self::TYPE_UNDEFINED) {
return $this->getCron();
}
// init
$elements = [];
$elements[] = sprintf($texts['text_period'], $texts['name_' . $type]);
// hour
if ($type === self::TYPE_HOUR) {
$elements[] = sprintf($texts['text_mins'], $this->getCronMinutes());
}
// week
if ($type === self::TYPE_WEEK) {
$dow = $this->getCronDaysOfWeek();
foreach ($texts['weekdays'] as $i => $wd) {
$dow = str_replace((string) ($i + 1), $wd, $dow);
}
$elements[] = sprintf($texts['text_dow'], $dow);
}
// month + year
if (in_array($type, [self::TYPE_MONTH, self::TYPE_YEAR], true)) {
$elements[] = sprintf($texts['text_dom'], $this->getCronDaysOfMonth());
}
// year
if ($type === self::TYPE_YEAR) {
$months = $this->getCronMonths();
for ($i = count($texts['months']) - 1; $i >= 0; $i--) {
$months = str_replace((string) ($i + 1), $texts['months'][$i], $months);
}
$elements[] = sprintf($texts['text_month'], $months);
}
// day + week + month + year
if (in_array($type, [self::TYPE_DAY, self::TYPE_WEEK, self::TYPE_MONTH, self::TYPE_YEAR], true)) {
$elements[] = sprintf($texts['text_time'], $this->getCronHours(), $this->getCronMinutes());
}
return str_replace('*', $texts['empty'], implode(' ', $elements));
}
/**
* @return string
*/
public function getType()
{
$mask = preg_replace('/[^\* ]/', '-', $this->getCron());
$mask = preg_replace('/-+/', '-', $mask);
$mask = preg_replace('/[^-\*]/', '', $mask);
if ($mask === '*****') {
return self::TYPE_MINUTE;
}
if ($mask === '-****') {
return self::TYPE_HOUR;
}
if (substr($mask, -3) === '***') {
return self::TYPE_DAY;
}
if (substr($mask, -3) === '-**') {
return self::TYPE_MONTH;
}
if (substr($mask, -3) === '**-') {
return self::TYPE_WEEK;
}
if (substr($mask, -2) === '-*') {
return self::TYPE_YEAR;
}
return self::TYPE_UNDEFINED;
}
/**
* @param string $cron
* @return $this
*/
public function setCron($cron)
{
// sanitize
$cron = trim($cron);
$cron = preg_replace('/\s+/', ' ', $cron);
// explode
$elements = explode(' ', $cron);
if (count($elements) !== 5) {
throw new RuntimeException('Bad number of elements');
}
$this->cron = $cron;
$this->setMinutes($elements[0]);
$this->setHours($elements[1]);
$this->setDaysOfMonth($elements[2]);
$this->setMonths($elements[3]);
$this->setDaysOfWeek($elements[4]);
return $this;
}
/**
* @return string
*/
public function getCronMinutes()
{
return $this->arrayToCron($this->minutes);
}
/**
* @return string
*/
public function getCronHours()
{
return $this->arrayToCron($this->hours);
}
/**
* @return string
*/
public function getCronDaysOfMonth()
{
return $this->arrayToCron($this->dom);
}
/**
* @return string
*/
public function getCronMonths()
{
return $this->arrayToCron($this->months);
}
/**
* @return string
*/
public function getCronDaysOfWeek()
{
return $this->arrayToCron($this->dow);
}
/**
* @return array
*/
public function getMinutes()
{
return $this->minutes;
}
/**
* @return array
*/
public function getHours()
{
return $this->hours;
}
/**
* @return array
*/
public function getDaysOfMonth()
{
return $this->dom;
}
/**
* @return array
*/
public function getMonths()
{
return $this->months;
}
/**
* @return array
*/
public function getDaysOfWeek()
{
return $this->dow;
}
/**
* @param string|string[] $minutes
* @return $this
*/
public function setMinutes($minutes)
{
$this->minutes = $this->cronToArray($minutes, 0, 59);
return $this;
}
/**
* @param string|string[] $hours
* @return $this
*/
public function setHours($hours)
{
$this->hours = $this->cronToArray($hours, 0, 23);
return $this;
}
/**
* @param string|string[] $months
* @return $this
*/
public function setMonths($months)
{
$this->months = $this->cronToArray($months, 1, 12);
return $this;
}
/**
* @param string|string[] $dow
* @return $this
*/
public function setDaysOfWeek($dow)
{
$this->dow = $this->cronToArray($dow, 0, 7);
return $this;
}
/**
* @param string|string[] $dom
* @return $this
*/
public function setDaysOfMonth($dom)
{
$this->dom = $this->cronToArray($dom, 1, 31);
return $this;
}
/**
* @param mixed $date
* @param int $min
* @param int $hour
* @param int $day
* @param int $month
* @param int $weekday
* @return DateTime
*/
protected function parseDate($date, &$min, &$hour, &$day, &$month, &$weekday)
{
if (is_numeric($date) && (int)$date == $date) {
$date = new DateTime('@' . $date);
} elseif (is_string($date)) {
$date = new DateTime('@' . strtotime($date));
}
if ($date instanceof DateTime) {
$min = (int)$date->format('i');
$hour = (int)$date->format('H');
$day = (int)$date->format('d');
$month = (int)$date->format('m');
$weekday = (int)$date->format('w'); // 0-6
} else {
throw new RuntimeException('Date format not supported');
}
return new DateTime($date->format('Y-m-d H:i:sP'));
}
/**
* @param int|string|DateTime $date
*/
public function matchExact($date)
{
$date = $this->parseDate($date, $min, $hour, $day, $month, $weekday);
return
(empty($this->minutes) || in_array($min, $this->minutes, true)) &&
(empty($this->hours) || in_array($hour, $this->hours, true)) &&
(empty($this->dom) || in_array($day, $this->dom, true)) &&
(empty($this->months) || in_array($month, $this->months, true)) &&
(empty($this->dow) || in_array($weekday, $this->dow, true) || ($weekday == 0 && in_array(7, $this->dow, true)) || ($weekday == 7 && in_array(0, $this->dow, true))
);
}
/**
* @param int|string|DateTime $date
* @param int $minuteBefore
* @param int $minuteAfter
*/
public function matchWithMargin($date, $minuteBefore = 0, $minuteAfter = 0)
{
if ($minuteBefore > 0) {
throw new RuntimeException('MinuteBefore parameter cannot be positive !');
}
if ($minuteAfter < 0) {
throw new RuntimeException('MinuteAfter parameter cannot be negative !');
}
$date = $this->parseDate($date, $min, $hour, $day, $month, $weekday);
$interval = new DateInterval('PT1M'); // 1 min
if ($minuteBefore !== 0) {
$date->sub(new DateInterval('PT' . abs($minuteBefore) . 'M'));
}
$n = $minuteAfter - $minuteBefore + 1;
for ($i = 0; $i < $n; $i++) {
if ($this->matchExact($date)) {
return true;
}
$date->add($interval);
}
return false;
}
/**
* @param array $array
* @return string
*/
protected function arrayToCron($array)
{
$n = count($array);
if (!is_array($array) || $n === 0) {
return '*';
}
$cron = [$array[0]];
$s = $c = $array[0];
for ($i = 1; $i < $n; $i++) {
if ($array[$i] == $c + 1) {
$c = $array[$i];
$cron[count($cron) - 1] = $s . '-' . $c;
} else {
$s = $c = $array[$i];
$cron[] = $c;
}
}
return implode(',', $cron);
}
/**
*
* @param array|string $string
* @param int $min
* @param int $max
* @return array
*/
protected function cronToArray($string, $min, $max)
{
$array = [];
if (is_array($string)) {
foreach ($string as $val) {
if (is_numeric($val) && (int)$val == $val && $val >= $min && $val <= $max) {
$array[] = (int)$val;
}
}
} elseif ($string !== '*') {
while ($string !== '') {
// test "*/n" expression
if (preg_match('/^\*\/([0-9]+),?/', $string, $m)) {
for ($i = max(0, $min); $i <= min(59, $max); $i += $m[1]) {
$array[] = (int)$i;
}
$string = substr($string, strlen($m[0]));
continue;
}
// test "a-b/n" expression
if (preg_match('/^([0-9]+)-([0-9]+)\/([0-9]+),?/', $string, $m)) {
for ($i = max($m[1], $min); $i <= min($m[2], $max); $i += $m[3]) {
$array[] = (int)$i;
}
$string = substr($string, strlen($m[0]));
continue;
}
// test "a-b" expression
if (preg_match('/^([0-9]+)-([0-9]+),?/', $string, $m)) {
for ($i = max($m[1], $min); $i <= min($m[2], $max); $i++) {
$array[] = (int)$i;
}
$string = substr($string, strlen($m[0]));
continue;
}
// test "c" expression
if (preg_match('/^([0-9]+),?/', $string, $m)) {
if ($m[1] >= $min && $m[1] <= $max) {
$array[] = (int)$m[1];
}
$string = substr($string, strlen($m[0]));
continue;
}
// something goes wrong in the expression
return [];
}
}
sort($array, SORT_NUMERIC);
return $array;
}
}
@@ -0,0 +1,404 @@
<?php
/**
* @package Grav\Common\Scheduler
* @author Originally based on peppeocchi/php-cron-scheduler modified for Grav integration
* @copyright Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Scheduler;
use Cron\CronExpression;
use InvalidArgumentException;
use function is_string;
/**
* Trait IntervalTrait
* @package Grav\Common\Scheduler
*/
trait IntervalTrait
{
/**
* Set the Job execution time.
*compo
* @param string $expression
* @return self
*/
public function at($expression)
{
$this->at = $expression;
$this->executionTime = CronExpression::factory($expression);
return $this;
}
/**
* Set the execution time to every minute.
*
* @return self
*/
public function everyMinute()
{
return $this->at('* * * * *');
}
/**
* Set the execution time to every hour.
*
* @param int|string $minute
* @return self
*/
public function hourly($minute = 0)
{
$c = $this->validateCronSequence($minute);
return $this->at("{$c['minute']} * * * *");
}
/**
* Set the execution time to once a day.
*
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function daily($hour = 0, $minute = 0)
{
if (is_string($hour)) {
$parts = explode(':', $hour);
$hour = $parts[0];
$minute = $parts[1] ?? '0';
}
$c = $this->validateCronSequence($minute, $hour);
return $this->at("{$c['minute']} {$c['hour']} * * *");
}
/**
* Set the execution time to once a week.
*
* @param int|string $weekday
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function weekly($weekday = 0, $hour = 0, $minute = 0)
{
if (is_string($hour)) {
$parts = explode(':', $hour);
$hour = $parts[0];
$minute = $parts[1] ?? '0';
}
$c = $this->validateCronSequence($minute, $hour, null, null, $weekday);
return $this->at("{$c['minute']} {$c['hour']} * * {$c['weekday']}");
}
/**
* Set the execution time to once a month.
*
* @param int|string $month
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function monthly($month = '*', $day = 1, $hour = 0, $minute = 0)
{
if (is_string($hour)) {
$parts = explode(':', $hour);
$hour = $parts[0];
$minute = $parts[1] ?? '0';
}
$c = $this->validateCronSequence($minute, $hour, $day, $month);
return $this->at("{$c['minute']} {$c['hour']} {$c['day']} {$c['month']} *");
}
/**
* Set the execution time to every Sunday.
*
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function sunday($hour = 0, $minute = 0)
{
return $this->weekly(0, $hour, $minute);
}
/**
* Set the execution time to every Monday.
*
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function monday($hour = 0, $minute = 0)
{
return $this->weekly(1, $hour, $minute);
}
/**
* Set the execution time to every Tuesday.
*
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function tuesday($hour = 0, $minute = 0)
{
return $this->weekly(2, $hour, $minute);
}
/**
* Set the execution time to every Wednesday.
*
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function wednesday($hour = 0, $minute = 0)
{
return $this->weekly(3, $hour, $minute);
}
/**
* Set the execution time to every Thursday.
*
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function thursday($hour = 0, $minute = 0)
{
return $this->weekly(4, $hour, $minute);
}
/**
* Set the execution time to every Friday.
*
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function friday($hour = 0, $minute = 0)
{
return $this->weekly(5, $hour, $minute);
}
/**
* Set the execution time to every Saturday.
*
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function saturday($hour = 0, $minute = 0)
{
return $this->weekly(6, $hour, $minute);
}
/**
* Set the execution time to every January.
*
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function january($day = 1, $hour = 0, $minute = 0)
{
return $this->monthly(1, $day, $hour, $minute);
}
/**
* Set the execution time to every February.
*
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function february($day = 1, $hour = 0, $minute = 0)
{
return $this->monthly(2, $day, $hour, $minute);
}
/**
* Set the execution time to every March.
*
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function march($day = 1, $hour = 0, $minute = 0)
{
return $this->monthly(3, $day, $hour, $minute);
}
/**
* Set the execution time to every April.
*
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function april($day = 1, $hour = 0, $minute = 0)
{
return $this->monthly(4, $day, $hour, $minute);
}
/**
* Set the execution time to every May.
*
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function may($day = 1, $hour = 0, $minute = 0)
{
return $this->monthly(5, $day, $hour, $minute);
}
/**
* Set the execution time to every June.
*
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function june($day = 1, $hour = 0, $minute = 0)
{
return $this->monthly(6, $day, $hour, $minute);
}
/**
* Set the execution time to every July.
*
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function july($day = 1, $hour = 0, $minute = 0)
{
return $this->monthly(7, $day, $hour, $minute);
}
/**
* Set the execution time to every August.
*
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function august($day = 1, $hour = 0, $minute = 0)
{
return $this->monthly(8, $day, $hour, $minute);
}
/**
* Set the execution time to every September.
*
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function september($day = 1, $hour = 0, $minute = 0)
{
return $this->monthly(9, $day, $hour, $minute);
}
/**
* Set the execution time to every October.
*
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function october($day = 1, $hour = 0, $minute = 0)
{
return $this->monthly(10, $day, $hour, $minute);
}
/**
* Set the execution time to every November.
*
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function november($day = 1, $hour = 0, $minute = 0)
{
return $this->monthly(11, $day, $hour, $minute);
}
/**
* Set the execution time to every December.
*
* @param int|string $day
* @param int|string $hour
* @param int|string $minute
* @return self
*/
public function december($day = 1, $hour = 0, $minute = 0)
{
return $this->monthly(12, $day, $hour, $minute);
}
/**
* Validate sequence of cron expression.
*
* @param int|string|null $minute
* @param int|string|null $hour
* @param int|string|null $day
* @param int|string|null $month
* @param int|string|null $weekday
* @return array
*/
private function validateCronSequence($minute = null, $hour = null, $day = null, $month = null, $weekday = null)
{
return [
'minute' => $this->validateCronRange($minute, 0, 59),
'hour' => $this->validateCronRange($hour, 0, 23),
'day' => $this->validateCronRange($day, 1, 31),
'month' => $this->validateCronRange($month, 1, 12),
'weekday' => $this->validateCronRange($weekday, 0, 6),
];
}
/**
* Validate sequence of cron expression.
*
* @param int|string|null $value
* @param int $min
* @param int $max
* @return mixed
*/
private function validateCronRange($value, $min, $max)
{
if ($value === null || $value === '*') {
return '*';
}
if (! is_numeric($value) ||
! ($value >= $min && $value <= $max)
) {
throw new InvalidArgumentException(
"Invalid value: it should be '*' or between {$min} and {$max}."
);
}
return $value;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,462 @@
<?php
/**
* @package Grav\Common\Scheduler
*
* @copyright Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Scheduler;
use DateTime;
use RocketTheme\Toolbox\File\JsonFile;
/**
* Job History Manager
*
* Provides comprehensive job execution history, logging, and analytics
*
* @package Grav\Common\Scheduler
*/
class JobHistory
{
/** @var string */
protected $historyPath;
/** @var int */
protected $retentionDays = 30;
/** @var int */
protected $maxOutputLength = 5000;
/**
* Constructor
*
* @param string $historyPath
* @param int $retentionDays
*/
public function __construct(string $historyPath, int $retentionDays = 30)
{
$this->historyPath = $historyPath;
$this->retentionDays = $retentionDays;
// Ensure history directory exists
if (!is_dir($this->historyPath)) {
mkdir($this->historyPath, 0755, true);
}
}
/**
* Log job execution
*
* @param Job $job
* @param array $metadata Additional metadata to store
* @return string Log entry ID
*/
public function logExecution(Job $job, array $metadata = []): string
{
$entryId = uniqid($job->getId() . '_', true);
$timestamp = new DateTime();
$entry = [
'id' => $entryId,
'job_id' => $job->getId(),
'command' => is_string($job->getCommand()) ? $job->getCommand() : 'Closure',
'arguments' => method_exists($job, 'getRawArguments') ? $job->getRawArguments() : $job->getArguments(),
'executed_at' => $timestamp->format('c'),
'timestamp' => $timestamp->getTimestamp(),
'success' => $job->isSuccessful(),
'output' => $this->captureOutput($job),
'execution_time' => method_exists($job, 'getExecutionTime') ? $job->getExecutionTime() : null,
'retry_count' => method_exists($job, 'getRetryCount') ? $job->getRetryCount() : 0,
'priority' => method_exists($job, 'getPriority') ? $job->getPriority() : 'normal',
'tags' => method_exists($job, 'getTags') ? $job->getTags() : [],
'metadata' => array_merge(
method_exists($job, 'getMetadata') ? $job->getMetadata() : [],
$metadata
),
];
// Store in daily file
$this->storeEntry($entry);
// Also store in job-specific history
$this->storeJobHistory($job->getId(), $entry);
return $entryId;
}
/**
* Capture job output with length limit
*
* @param Job $job
* @return array
*/
protected function captureOutput(Job $job): array
{
$output = $job->getOutput();
$truncated = false;
if (strlen($output) > $this->maxOutputLength) {
$output = substr($output, 0, $this->maxOutputLength);
$truncated = true;
}
return [
'content' => $output,
'truncated' => $truncated,
'length' => strlen($job->getOutput()),
];
}
/**
* Store entry in daily log file
*
* @param array $entry
* @return void
*/
protected function storeEntry(array $entry): void
{
$date = date('Y-m-d');
$filename = $this->historyPath . '/' . $date . '.json';
$jsonFile = JsonFile::instance($filename);
$entries = $jsonFile->content() ?: [];
$entries[] = $entry;
$jsonFile->save($entries);
}
/**
* Store job-specific history
*
* @param string $jobId
* @param array $entry
* @return void
*/
protected function storeJobHistory(string $jobId, array $entry): void
{
$jobDir = $this->historyPath . '/jobs';
if (!is_dir($jobDir)) {
mkdir($jobDir, 0755, true);
}
$filename = $jobDir . '/' . $jobId . '.json';
$jsonFile = JsonFile::instance($filename);
$history = $jsonFile->content() ?: [];
// Keep only last 100 executions per job
$history[] = $entry;
if (count($history) > 100) {
$history = array_slice($history, -100);
}
$jsonFile->save($history);
}
/**
* Get job history
*
* @param string $jobId
* @param int $limit
* @return array
*/
public function getJobHistory(string $jobId, int $limit = 50): array
{
$filename = $this->historyPath . '/jobs/' . $jobId . '.json';
if (!file_exists($filename)) {
return [];
}
$jsonFile = JsonFile::instance($filename);
$history = $jsonFile->content() ?: [];
// Return most recent first
$history = array_reverse($history);
if ($limit > 0) {
$history = array_slice($history, 0, $limit);
}
return $history;
}
/**
* Get history for a date range
*
* @param DateTime $startDate
* @param DateTime $endDate
* @param string|null $jobId Filter by job ID
* @return array
*/
public function getHistoryRange(DateTime $startDate, DateTime $endDate, ?string $jobId = null): array
{
$history = [];
$current = clone $startDate;
while ($current <= $endDate) {
$filename = $this->historyPath . '/' . $current->format('Y-m-d') . '.json';
if (file_exists($filename)) {
$jsonFile = JsonFile::instance($filename);
$entries = $jsonFile->content() ?: [];
foreach ($entries as $entry) {
if ($jobId === null || $entry['job_id'] === $jobId) {
$history[] = $entry;
}
}
}
$current->modify('+1 day');
}
return $history;
}
/**
* Get job statistics
*
* @param string $jobId
* @param int $days Number of days to analyze
* @return array
*/
public function getJobStatistics(string $jobId, int $days = 7): array
{
$startDate = new DateTime("-{$days} days");
$endDate = new DateTime('now');
$history = $this->getHistoryRange($startDate, $endDate, $jobId);
if (empty($history)) {
return [
'total_runs' => 0,
'successful_runs' => 0,
'failed_runs' => 0,
'success_rate' => 0,
'average_execution_time' => 0,
'last_run' => null,
'last_success' => null,
'last_failure' => null,
];
}
$totalRuns = count($history);
$successfulRuns = 0;
$executionTimes = [];
$lastRun = null;
$lastSuccess = null;
$lastFailure = null;
foreach ($history as $entry) {
if ($entry['success']) {
$successfulRuns++;
if (!$lastSuccess || $entry['timestamp'] > $lastSuccess['timestamp']) {
$lastSuccess = $entry;
}
} else {
if (!$lastFailure || $entry['timestamp'] > $lastFailure['timestamp']) {
$lastFailure = $entry;
}
}
if (!$lastRun || $entry['timestamp'] > $lastRun['timestamp']) {
$lastRun = $entry;
}
if (isset($entry['execution_time']) && $entry['execution_time'] > 0) {
$executionTimes[] = $entry['execution_time'];
}
}
return [
'total_runs' => $totalRuns,
'successful_runs' => $successfulRuns,
'failed_runs' => $totalRuns - $successfulRuns,
'success_rate' => $totalRuns > 0 ? round(($successfulRuns / $totalRuns) * 100, 2) : 0,
'average_execution_time' => !empty($executionTimes) ? round(array_sum($executionTimes) / count($executionTimes), 3) : 0,
'last_run' => $lastRun,
'last_success' => $lastSuccess,
'last_failure' => $lastFailure,
];
}
/**
* Get global statistics
*
* @param int $days
* @return array
*/
public function getGlobalStatistics(int $days = 7): array
{
$startDate = new DateTime("-{$days} days");
$endDate = new DateTime('now');
$history = $this->getHistoryRange($startDate, $endDate);
$jobStats = [];
foreach ($history as $entry) {
$jobId = $entry['job_id'];
if (!isset($jobStats[$jobId])) {
$jobStats[$jobId] = [
'runs' => 0,
'success' => 0,
'failed' => 0,
];
}
$jobStats[$jobId]['runs']++;
if ($entry['success']) {
$jobStats[$jobId]['success']++;
} else {
$jobStats[$jobId]['failed']++;
}
}
return [
'total_executions' => count($history),
'unique_jobs' => count($jobStats),
'job_statistics' => $jobStats,
'period_days' => $days,
'from_date' => $startDate->format('Y-m-d'),
'to_date' => $endDate->format('Y-m-d'),
];
}
/**
* Search history
*
* @param array $criteria
* @return array
*/
public function searchHistory(array $criteria): array
{
$results = [];
// Determine date range
$startDate = isset($criteria['start_date']) ? new DateTime($criteria['start_date']) : new DateTime('-7 days');
$endDate = isset($criteria['end_date']) ? new DateTime($criteria['end_date']) : new DateTime('now');
$history = $this->getHistoryRange($startDate, $endDate, $criteria['job_id'] ?? null);
foreach ($history as $entry) {
$match = true;
// Filter by success status
if (isset($criteria['success']) && $entry['success'] !== $criteria['success']) {
$match = false;
}
// Filter by output content
if (isset($criteria['output_contains']) &&
stripos($entry['output']['content'], $criteria['output_contains']) === false) {
$match = false;
}
// Filter by tags
if (isset($criteria['tags']) && is_array($criteria['tags'])) {
$entryTags = $entry['tags'] ?? [];
if (empty(array_intersect($criteria['tags'], $entryTags))) {
$match = false;
}
}
if ($match) {
$results[] = $entry;
}
}
// Sort results
if (isset($criteria['sort_by'])) {
usort($results, function($a, $b) use ($criteria) {
$field = $criteria['sort_by'];
$order = $criteria['sort_order'] ?? 'desc';
$aVal = $a[$field] ?? 0;
$bVal = $b[$field] ?? 0;
if ($order === 'asc') {
return $aVal <=> $bVal;
} else {
return $bVal <=> $aVal;
}
});
}
// Limit results
if (isset($criteria['limit'])) {
$results = array_slice($results, 0, $criteria['limit']);
}
return $results;
}
/**
* Clean old history files
*
* @return int Number of files deleted
*/
public function cleanOldHistory(): int
{
$deleted = 0;
$cutoffDate = new DateTime("-{$this->retentionDays} days");
$files = glob($this->historyPath . '/*.json');
foreach ($files as $file) {
$filename = basename($file, '.json');
// Check if filename is a date
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $filename)) {
$fileDate = new DateTime($filename);
if ($fileDate < $cutoffDate) {
unlink($file);
$deleted++;
}
}
}
return $deleted;
}
/**
* Export history to CSV
*
* @param array $history
* @param string $filename
* @return bool
*/
public function exportToCsv(array $history, string $filename): bool
{
$handle = fopen($filename, 'w');
if (!$handle) {
return false;
}
// Write headers
fputcsv($handle, [
'Job ID',
'Executed At',
'Success',
'Execution Time',
'Output Length',
'Retry Count',
'Priority',
'Tags',
]);
// Write data
foreach ($history as $entry) {
fputcsv($handle, [
$entry['job_id'],
$entry['executed_at'],
$entry['success'] ? 'Yes' : 'No',
$entry['execution_time'] ?? '',
$entry['output']['length'] ?? 0,
$entry['retry_count'] ?? 0,
$entry['priority'] ?? 'normal',
implode(', ', $entry['tags'] ?? []),
]);
}
fclose($handle);
return true;
}
}
@@ -0,0 +1,588 @@
<?php
/**
* @package Grav\Common\Scheduler
*
* @copyright Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Scheduler;
use RocketTheme\Toolbox\File\JsonFile;
use RuntimeException;
/**
* File-based job queue implementation
*
* @package Grav\Common\Scheduler
*/
class JobQueue
{
/** @var string */
protected $queuePath;
/** @var string */
protected $lockFile;
/** @var array Priority levels */
const PRIORITY_HIGH = 'high';
const PRIORITY_NORMAL = 'normal';
const PRIORITY_LOW = 'low';
/**
* JobQueue constructor
*
* @param string $queuePath
*/
public function __construct(string $queuePath)
{
$this->queuePath = $queuePath;
$this->lockFile = $queuePath . '/.lock';
// Create queue directories
$this->initializeDirectories();
}
/**
* Initialize queue directories
*
* @return void
*/
protected function initializeDirectories(): void
{
$dirs = [
$this->queuePath . '/pending',
$this->queuePath . '/processing',
$this->queuePath . '/failed',
$this->queuePath . '/completed',
];
foreach ($dirs as $dir) {
if (!file_exists($dir)) {
mkdir($dir, 0755, true);
}
}
}
/**
* Push a job to the queue
*
* @param Job $job
* @param string $priority
* @return string Job queue ID
*/
public function push(Job $job, string $priority = self::PRIORITY_NORMAL): string
{
$queueId = $this->generateQueueId($job);
$timestamp = microtime(true);
$queueItem = [
'id' => $queueId,
'job_id' => $job->getId(),
'command' => is_string($job->getCommand()) ? $job->getCommand() : 'Closure',
'arguments' => method_exists($job, 'getRawArguments') ? $job->getRawArguments() : $job->getArguments(),
'priority' => $priority,
'timestamp' => $timestamp,
'attempts' => 0,
'max_attempts' => method_exists($job, 'getMaxAttempts') ? $job->getMaxAttempts() : 1,
'created_at' => date('c'),
'scheduled_for' => null,
'metadata' => [],
];
// Always serialize the job to preserve its full state
$queueItem['serialized_job'] = base64_encode(serialize($job));
$this->writeQueueItem($queueItem, 'pending');
return $queueId;
}
/**
* Push a job for delayed execution
*
* @param Job $job
* @param \DateTime $scheduledFor
* @param string $priority
* @return string
*/
public function pushDelayed(Job $job, \DateTime $scheduledFor, string $priority = self::PRIORITY_NORMAL): string
{
$queueId = $this->push($job, $priority);
// Update the scheduled time
$item = $this->getQueueItem($queueId, 'pending');
if ($item) {
$item['scheduled_for'] = $scheduledFor->format('c');
$this->writeQueueItem($item, 'pending');
}
return $queueId;
}
/**
* Pop the next job from the queue
*
* @return Job|null
*/
public function pop(): ?Job
{
if (!$this->lock()) {
return null;
}
try {
// Get all pending items
$items = $this->getPendingItems();
if (empty($items)) {
$this->unlock();
return null;
}
// Sort by priority and timestamp
usort($items, function($a, $b) {
$priorityOrder = [
self::PRIORITY_HIGH => 0,
self::PRIORITY_NORMAL => 1,
self::PRIORITY_LOW => 2,
];
$aPriority = $priorityOrder[$a['priority']] ?? 1;
$bPriority = $priorityOrder[$b['priority']] ?? 1;
if ($aPriority !== $bPriority) {
return $aPriority - $bPriority;
}
return $a['timestamp'] <=> $b['timestamp'];
});
// Get the first item that's ready to run
$now = new \DateTime();
foreach ($items as $item) {
if ($item['scheduled_for']) {
$scheduledTime = new \DateTime($item['scheduled_for']);
if ($scheduledTime > $now) {
continue; // Skip items not yet due
}
}
// Move to processing
$this->moveQueueItem($item['id'], 'pending', 'processing');
// Reconstruct the job
$job = $this->reconstructJob($item);
$this->unlock();
return $job;
}
$this->unlock();
return null;
} catch (\Exception $e) {
$this->unlock();
throw $e;
}
}
/**
* Pop a job from the queue with its queue ID
*
* @return array|null Array with 'job' and 'id' keys
*/
public function popWithId(): ?array
{
if (!$this->lock()) {
return null;
}
try {
// Get all pending items
$items = $this->getPendingItems();
if (empty($items)) {
$this->unlock();
return null;
}
// Sort by priority and timestamp
usort($items, function($a, $b) {
$priorityOrder = [
self::PRIORITY_HIGH => 0,
self::PRIORITY_NORMAL => 1,
self::PRIORITY_LOW => 2,
];
$aPriority = $priorityOrder[$a['priority']] ?? 1;
$bPriority = $priorityOrder[$b['priority']] ?? 1;
if ($aPriority !== $bPriority) {
return $aPriority - $bPriority;
}
return $a['timestamp'] <=> $b['timestamp'];
});
// Get the first item that's ready to run
$now = new \DateTime();
foreach ($items as $item) {
if ($item['scheduled_for']) {
$scheduledTime = new \DateTime($item['scheduled_for']);
if ($scheduledTime > $now) {
continue; // Skip items not yet due
}
}
// Reconstruct the job first before moving it
$job = $this->reconstructJob($item);
if (!$job) {
// Failed to reconstruct, skip this item
continue;
}
// Move to processing only if we can reconstruct the job
$this->moveQueueItem($item['id'], 'pending', 'processing');
$this->unlock();
return ['job' => $job, 'id' => $item['id']];
}
$this->unlock();
return null;
} catch (\Exception $e) {
$this->unlock();
throw $e;
}
}
/**
* Mark a job as completed
*
* @param string $queueId
* @return void
*/
public function complete(string $queueId): void
{
$this->moveQueueItem($queueId, 'processing', 'completed');
// Clean up old completed items
$this->cleanupCompleted();
}
/**
* Mark a job as failed
*
* @param string $queueId
* @param string $error
* @return void
*/
public function fail(string $queueId, string $error = ''): void
{
$item = $this->getQueueItem($queueId, 'processing');
if ($item) {
$item['attempts']++;
$item['last_error'] = $error;
$item['failed_at'] = date('c');
if ($item['attempts'] < $item['max_attempts']) {
// Move back to pending for retry
$item['retry_at'] = $this->calculateRetryTime($item['attempts']);
$item['scheduled_for'] = $item['retry_at'];
$this->writeQueueItem($item, 'pending');
$this->deleteQueueItem($queueId, 'processing');
} else {
// Move to failed (dead letter queue)
$this->writeQueueItem($item, 'failed');
$this->deleteQueueItem($queueId, 'processing');
}
}
}
/**
* Get queue size
*
* @return int
*/
public function size(): int
{
return count($this->getPendingItems());
}
/**
* Check if queue is empty
*
* @return bool
*/
public function isEmpty(): bool
{
return $this->size() === 0;
}
/**
* Get queue statistics
*
* @return array
*/
public function getStatistics(): array
{
return [
'pending' => count($this->getPendingItems()),
'processing' => count($this->getItemsInDirectory('processing')),
'failed' => count($this->getItemsInDirectory('failed')),
'completed_today' => $this->countCompletedToday(),
];
}
/**
* Generate a unique queue ID
*
* @param Job $job
* @return string
*/
protected function generateQueueId(Job $job): string
{
return $job->getId() . '_' . uniqid('', true);
}
/**
* Write queue item to disk
*
* @param array $item
* @param string $directory
* @return void
*/
protected function writeQueueItem(array $item, string $directory): void
{
$path = $this->queuePath . '/' . $directory . '/' . $item['id'] . '.json';
$file = JsonFile::instance($path);
$file->save($item);
}
/**
* Read queue item from disk
*
* @param string $queueId
* @param string $directory
* @return array|null
*/
protected function getQueueItem(string $queueId, string $directory): ?array
{
$path = $this->queuePath . '/' . $directory . '/' . $queueId . '.json';
if (!file_exists($path)) {
return null;
}
$file = JsonFile::instance($path);
return $file->content();
}
/**
* Delete queue item
*
* @param string $queueId
* @param string $directory
* @return void
*/
protected function deleteQueueItem(string $queueId, string $directory): void
{
$path = $this->queuePath . '/' . $directory . '/' . $queueId . '.json';
if (file_exists($path)) {
unlink($path);
}
}
/**
* Move queue item between directories
*
* @param string $queueId
* @param string $fromDir
* @param string $toDir
* @return void
*/
protected function moveQueueItem(string $queueId, string $fromDir, string $toDir): void
{
$fromPath = $this->queuePath . '/' . $fromDir . '/' . $queueId . '.json';
$toPath = $this->queuePath . '/' . $toDir . '/' . $queueId . '.json';
if (file_exists($fromPath)) {
rename($fromPath, $toPath);
}
}
/**
* Get all pending items
*
* @return array
*/
protected function getPendingItems(): array
{
return $this->getItemsInDirectory('pending');
}
/**
* Get items in a specific directory
*
* @param string $directory
* @return array
*/
protected function getItemsInDirectory(string $directory): array
{
$items = [];
$path = $this->queuePath . '/' . $directory;
if (!is_dir($path)) {
return $items;
}
$files = glob($path . '/*.json');
foreach ($files as $file) {
$jsonFile = JsonFile::instance($file);
$items[] = $jsonFile->content();
}
return $items;
}
/**
* Reconstruct a job from queue item
*
* @param array $item
* @return Job|null
*/
protected function reconstructJob(array $item): ?Job
{
if (isset($item['serialized_job'])) {
// Unserialize the job
try {
$job = unserialize(base64_decode($item['serialized_job']));
if ($job instanceof Job) {
return $job;
}
} catch (\Exception $e) {
// Failed to unserialize
return null;
}
}
// Create a new job from command
if (isset($item['command'])) {
$args = $item['arguments'] ?? [];
$job = new Job($item['command'], $args, $item['job_id']);
return $job;
}
return null;
}
/**
* Calculate retry time with exponential backoff
*
* @param int $attempts
* @return string
*/
protected function calculateRetryTime(int $attempts): string
{
$backoffSeconds = min(pow(2, $attempts) * 60, 3600); // Max 1 hour
$retryTime = new \DateTime();
$retryTime->modify("+{$backoffSeconds} seconds");
return $retryTime->format('c');
}
/**
* Clean up old completed items
*
* @return void
*/
protected function cleanupCompleted(): void
{
$items = $this->getItemsInDirectory('completed');
$cutoff = new \DateTime('-24 hours');
foreach ($items as $item) {
if (isset($item['created_at'])) {
$createdAt = new \DateTime($item['created_at']);
if ($createdAt < $cutoff) {
$this->deleteQueueItem($item['id'], 'completed');
}
}
}
}
/**
* Count completed jobs today
*
* @return int
*/
protected function countCompletedToday(): int
{
$items = $this->getItemsInDirectory('completed');
$today = new \DateTime('today');
$count = 0;
foreach ($items as $item) {
if (isset($item['created_at'])) {
$createdAt = new \DateTime($item['created_at']);
if ($createdAt >= $today) {
$count++;
}
}
}
return $count;
}
/**
* Acquire lock for queue operations
*
* @return bool
*/
protected function lock(): bool
{
$attempts = 0;
$maxAttempts = 50; // 5 seconds total
while ($attempts < $maxAttempts) {
// Check if lock file exists and is stale (older than 30 seconds)
if (file_exists($this->lockFile)) {
$lockAge = time() - filemtime($this->lockFile);
if ($lockAge > 30) {
// Stale lock, remove it
@unlink($this->lockFile);
}
}
// Try to acquire lock atomically
$handle = @fopen($this->lockFile, 'x');
if ($handle !== false) {
fclose($handle);
return true;
}
$attempts++;
usleep(100000); // 100ms
}
// Could not acquire lock
return false;
}
/**
* Release queue lock
*
* @return void
*/
protected function unlock(): void
{
if (file_exists($this->lockFile)) {
unlink($this->lockFile);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,270 @@
<?php
/**
* @package Grav\Common\Scheduler
*
* @copyright Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Scheduler;
use Grav\Common\Grav;
use Grav\Common\Utils;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* Scheduler Controller for handling HTTP endpoints
*
* @package Grav\Common\Scheduler
*/
class SchedulerController
{
/** @var Grav */
protected $grav;
/** @var ModernScheduler */
protected $scheduler;
/**
* SchedulerController constructor
*
* @param Grav $grav
*/
public function __construct(Grav $grav)
{
$this->grav = $grav;
// Get scheduler instance
$scheduler = $grav['scheduler'];
if ($scheduler instanceof ModernScheduler) {
$this->scheduler = $scheduler;
} else {
// Create ModernScheduler instance if not already
$this->scheduler = new ModernScheduler();
}
}
/**
* Handle health check endpoint
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function health(ServerRequestInterface $request): ResponseInterface
{
$config = $this->grav['config']->get('scheduler.modern', []);
// Check if health endpoint is enabled
if (!($config['health']['enabled'] ?? true)) {
return $this->jsonResponse(['error' => 'Health check disabled'], 403);
}
// Get health status
$health = $this->scheduler->getHealthStatus();
return $this->jsonResponse($health);
}
/**
* Handle webhook trigger endpoint
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function webhook(ServerRequestInterface $request): ResponseInterface
{
$config = $this->grav['config']->get('scheduler.modern', []);
// Check if webhook is enabled
if (!($config['webhook']['enabled'] ?? false)) {
return $this->jsonResponse(['error' => 'Webhook triggers disabled'], 403);
}
// Get authorization header
$authHeader = $request->getHeaderLine('Authorization');
$token = null;
if (preg_match('/Bearer\s+(.+)$/i', $authHeader, $matches)) {
$token = $matches[1];
}
// Get query parameters
$params = $request->getQueryParams();
$jobId = $params['job'] ?? null;
// Process webhook
$result = $this->scheduler->processWebhookTrigger($token, $jobId);
$statusCode = $result['success'] ? 200 : 400;
return $this->jsonResponse($result, $statusCode);
}
/**
* Handle statistics endpoint
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function statistics(ServerRequestInterface $request): ResponseInterface
{
// Check if user is admin
$user = $this->grav['user'] ?? null;
if (!$user || !$user->authorize('admin.super')) {
return $this->jsonResponse(['error' => 'Unauthorized'], 401);
}
$stats = $this->scheduler->getStatistics();
return $this->jsonResponse($stats);
}
/**
* Handle admin AJAX requests for scheduler status
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function adminStatus(ServerRequestInterface $request): ResponseInterface
{
// Check if user is admin
$user = $this->grav['user'] ?? null;
if (!$user || !$user->authorize('admin.scheduler')) {
return $this->jsonResponse(['error' => 'Unauthorized'], 401);
}
$health = $this->scheduler->getHealthStatus();
// Format for admin display
$response = [
'health' => $this->formatHealthStatus($health),
'triggers' => $this->formatTriggers($health['trigger_methods'] ?? [])
];
return $this->jsonResponse($response);
}
/**
* Format health status for display
*
* @param array $health
* @return string
*/
protected function formatHealthStatus(array $health): string
{
$status = $health['status'] ?? 'unknown';
$lastRun = $health['last_run'] ?? null;
$queueSize = $health['queue_size'] ?? 0;
$failedJobs = $health['failed_jobs_24h'] ?? 0;
$jobsDue = $health['jobs_due'] ?? 0;
$message = $health['message'] ?? '';
$statusBadge = match($status) {
'healthy' => '<span class="badge badge-success">Healthy</span>',
'warning' => '<span class="badge badge-warning">Warning</span>',
'critical' => '<span class="badge badge-danger">Critical</span>',
default => '<span class="badge badge-secondary">Unknown</span>'
};
$html = '<div class="scheduler-health">';
$html .= '<p>Status: ' . $statusBadge;
if ($message) {
$html .= ' - ' . htmlspecialchars($message);
}
$html .= '</p>';
if ($lastRun) {
$lastRunTime = new \DateTime($lastRun);
$now = new \DateTime();
$diff = $now->diff($lastRunTime);
$timeAgo = '';
if ($diff->d > 0) {
$timeAgo = $diff->d . ' day' . ($diff->d > 1 ? 's' : '') . ' ago';
} elseif ($diff->h > 0) {
$timeAgo = $diff->h . ' hour' . ($diff->h > 1 ? 's' : '') . ' ago';
} elseif ($diff->i > 0) {
$timeAgo = $diff->i . ' minute' . ($diff->i > 1 ? 's' : '') . ' ago';
} else {
$timeAgo = 'Less than a minute ago';
}
$html .= '<p>Last Run: <strong>' . $timeAgo . '</strong></p>';
} else {
$html .= '<p>Last Run: <strong>Never</strong></p>';
}
$html .= '<p>Jobs Due: <strong>' . $jobsDue . '</strong></p>';
$html .= '<p>Queue Size: <strong>' . $queueSize . '</strong></p>';
if ($failedJobs > 0) {
$html .= '<p class="text-danger">Failed Jobs (24h): <strong>' . $failedJobs . '</strong></p>';
}
$html .= '</div>';
return $html;
}
/**
* Format triggers for display
*
* @param array $triggers
* @return string
*/
protected function formatTriggers(array $triggers): string
{
if (empty($triggers)) {
return '<div class="alert alert-warning">No active triggers detected. Please set up cron, systemd, or webhook triggers.</div>';
}
$html = '<div class="scheduler-triggers">';
$html .= '<ul class="list-unstyled">';
foreach ($triggers as $trigger) {
$icon = match($trigger) {
'cron' => '⏰',
'systemd' => '⚙️',
'webhook' => '🔗',
'external' => '🌐',
default => '•'
};
$label = match($trigger) {
'cron' => 'Cron Job',
'systemd' => 'Systemd Timer',
'webhook' => 'Webhook Triggers',
'external' => 'External Triggers',
default => ucfirst($trigger)
};
$html .= '<li>' . $icon . ' <strong>' . $label . '</strong> <span class="badge badge-success">Active</span></li>';
}
$html .= '</ul>';
$html .= '</div>';
return $html;
}
/**
* Create JSON response
*
* @param array $data
* @param int $statusCode
* @return ResponseInterface
*/
protected function jsonResponse(array $data, int $statusCode = 200): ResponseInterface
{
$response = $this->grav['response'] ?? new \Nyholm\Psr7\Response();
$response = $response->withStatus($statusCode)
->withHeader('Content-Type', 'application/json');
$body = $response->getBody();
$body->write(json_encode($data));
return $response;
}
}