diff --git a/app/Task.php b/app/Task.php index c98dc6c91..944012243 100644 --- a/app/Task.php +++ b/app/Task.php @@ -7,23 +7,26 @@ use Gazelle\Util\Irc; abstract class Task extends Base { protected array $events = []; protected int $processed = 0; - protected float $startTime; protected int $historyId; + protected float $startTime; + + abstract public function run(): void; public function __construct( - protected readonly int $taskId, - protected readonly string $name, - protected readonly bool $isDebug, - ) {} + public readonly int $taskId, + public readonly string $name, + public readonly bool $isDebug, + ) { + $this->startTime = microtime(true); + } public function begin(): void { - $this->startTime = microtime(true); self::$db->prepared_query(' INSERT INTO periodic_task_history (periodic_task_id) VALUES (?) - ', $this->taskId); - + ', $this->taskId + ); $this->historyId = self::$db->inserted_id(); } @@ -94,6 +97,4 @@ abstract class Task extends Base { public function error(string $message, int $reference = 0): void { $this->log($message, 'error', $reference); } - - abstract public function run(): void; } diff --git a/app/TaskScheduler.php b/app/TaskScheduler.php index bfb2fb95a..3894084a7 100644 --- a/app/TaskScheduler.php +++ b/app/TaskScheduler.php @@ -2,104 +2,59 @@ namespace Gazelle; -use Gazelle\Util\Irc; +/* Note: tasks are created and removed via a phinx migration. There + * is no ability to create a task from the site because the task + * code will need to be added via a repo commit in any event. + */ class TaskScheduler extends Base { final public const CACHE_TASKS = 'scheduled_tasks'; - public function getTask(int $id): ?array { - $tasks = $this->getTasks(); - return array_key_exists($id, $tasks) ? $tasks[$id] : null; + protected Util\SortableTableHeader $heading; + + public function findById(int $taskId): ?array { + $tasks = $this->taskList(); + return array_key_exists($taskId, $tasks) ? $tasks[$taskId] : null; } - public function getTasks(): array { - if (!$tasks = self::$cache->get_value(self::CACHE_TASKS)) { - self::$db->prepared_query(' - SELECT periodic_task_id, name, classname, description, period, is_enabled, is_sane, is_debug, run_now - FROM periodic_task - '); - - $tasks = self::$db->to_array('periodic_task_id', MYSQLI_ASSOC); - self::$cache->cache_value(self::CACHE_TASKS, $tasks, 3600); - } - - return $tasks; - } - - public function getInsaneTasks(): int { - return count(array_filter($this->getTasks(), - fn($v) => !$v['is_sane'] - )); - } - - public static function isClassValid(string $class): bool { - $class = 'Gazelle\\Task\\' . $class; - return class_exists($class); - } - - public function flush(): static { - self::$cache->delete_value(self::CACHE_TASKS); - return $this; - } - - public function createTask(string $name, string $class, string $description, int $period, bool $isEnabled, bool $isSane, bool $isDebug): void { - if (!self::isClassValid($class)) { - return; - } - - self::$db->prepared_query(" - INSERT INTO periodic_task - (name, classname, description, period, is_enabled, is_sane, is_debug) - VALUES - (?, ?, ?, ?, ?, ?, ?) - ", $name, $class, $description, $period, (int)$isEnabled, (int)$isSane, (int)$isDebug); - $this->flush(); - } - - public function updateTask(int $id, string $name, string $class, string $description, int $period, bool $isEnabled, bool $isSane, bool $isDebug): void { - if (!self::isClassValid($class)) { - return; - } - self::$db->prepared_query(" - UPDATE periodic_task SET - name = ?, - classname = ?, - description = ?, - period = ?, - is_enabled = ?, - is_sane = ?, - is_debug = ? - WHERE periodic_task_id = ? - ", $name, $class, $description, $period, (int)$isEnabled, (int)$isSane, (int)$isDebug, $id); - $this->flush(); - } - - public function runNow(int $id): void { - self::$db->prepared_query(" - UPDATE periodic_task SET - run_now = 1 - run_now - WHERE periodic_task_id = ? - ", $id + public function findByName(string $className): ?array { + return $this->findById( + (int)self::$db->scalar(" + SELECT pt.periodic_task_id FROM periodic_task pt WHERE pt.classname = ? + ", $className + ) ); - $this->flush(); } - public function deleteTask(int $id): void { - self::$db->prepared_query(" - DELETE FROM periodic_task WHERE periodic_task_id = ? - ", $id); - $this->flush(); + public function heading(): Util\SortableTableHeader { + return $this->heading ??= new Util\SortableTableHeader( + 'next', [ + 'name' => ['dbColumn' => 'name', 'defaultSort' => 'asc', 'text' => 'Name'], + 'period' => ['dbColumn' => 'period', 'defaultSort' => 'asc', 'text' => 'Interval'], + 'runs' => ['dbColumn' => 'runs', 'defaultSort' => 'desc', 'text' => 'Runs'], + 'duration' => ['dbColumn' => 'duration', 'defaultSort' => 'desc', 'text' => 'Duration'], + 'processed' => ['dbColumn' => 'processed', 'defaultSort' => 'desc', 'text' => 'Processed'], + 'status' => ['dbColumn' => 'status', 'defaultSort' => 'desc', 'text' => 'Status'], + 'errors' => ['dbColumn' => 'errors', 'defaultSort' => 'desc', 'text' => 'Errors'], + 'events' => ['dbColumn' => 'events', 'defaultSort' => 'desc', 'text' => 'Events'], + 'last' => ['dbColumn' => "last_run IS NULL ASC, is_enabled DESC, last_run", 'defaultSort' => 'desc', 'text' => 'Last Run'], + 'next' => ['dbColumn' => 'next_run IS NULL ASC, is_enabled DESC, next_run', 'defaultSort' => 'desc', 'text' => 'Next Run'], + ] + ); } - public function getTaskDetails(int $days = 7): array { + public function taskDetailList(int $days = 7): array { self::$db->prepared_query(" SELECT pt.periodic_task_id, name, description, period, is_enabled, is_sane, run_now, - coalesce(stats.runs, 0) runs, coalesce(stats.processed, 0) processed, - coalesce(stats.errors, 0) errors, coalesce(events.events, 0) events, - coalesce(pth.launch_time, '') last_run, - coalesce(pth.duration_ms, 0) duration, - coalesce(pth.status, '') status, - if(pth.launch_time is null, now(), pth.launch_time + INTERVAL period SECOND) AS next_run + coalesce(stats.runs, 0) AS runs, + coalesce(stats.processed, 0) AS processed, + coalesce(stats.errors, 0) AS errors, + coalesce(events.events, 0) AS events, + coalesce(pth.duration_ms, 0) AS duration, + coalesce(pth.status, '') AS status, + pth.launch_time AS last_run, + pth.launch_time + INTERVAL period SECOND + AS next_run FROM periodic_task pt LEFT JOIN ( @@ -118,41 +73,80 @@ class TaskScheduler extends Base { GROUP BY pth.periodic_task_id ) events ON (pt.periodic_task_id = events.periodic_task_id) LEFT JOIN periodic_task_history pth ON (stats.latest = pth.periodic_task_history_id) - ORDER BY pt.run_now DESC, pt.is_enabled DESC, pt.period, pt.periodic_task_id - ", $days, $days); - + ORDER BY {$this->heading()->orderBy()} {$this->heading()->dir()}, name ASC + ", $days, $days + ); return self::$db->to_array('periodic_task_id', MYSQLI_ASSOC); } - public function getTotal(int $id): int { + public function taskList(): array { + self::$db->prepared_query(" + SELECT periodic_task_id, name, classname, description, period, is_enabled, is_sane, is_debug, run_now + FROM periodic_task + "); + return self::$db->to_array('periodic_task_id', MYSQLI_ASSOC); + } + + public function insaneTaskList(): int { + return count(array_filter($this->taskList(), + fn ($v) => !$v['is_sane'] + )); + } + + public static function isClassValid(string $class): bool { + return class_exists('Gazelle\\Task\\' . $class); + } + + public function updateTask( + int $taskId, + string $name, + string $class, + string $description, + int $period, + bool $isEnabled, + bool $isSane, + bool $isDebug + ): int { + if (!self::isClassValid($class)) { + return 0; + } + self::$db->prepared_query(" + UPDATE periodic_task SET + name = ?, + classname = ?, + description = ?, + period = ?, + is_enabled = ?, + is_sane = ?, + is_debug = ? + WHERE periodic_task_id = ? + ", $name, $class, $description, $period, + (int)$isEnabled, (int)$isSane, (int)$isDebug, + $taskId, + ); + return self::$db->affected_rows(); + } + + public function taskRunTotal(int $taskId): int { return (int)self::$db->scalar(" SELECT count(*) FROM periodic_task_history WHERE periodic_task_id = ? - ", $id + ", $taskId ); } - public function getTaskHistory(int $id, int $limit, int $offset, string $sort, string $direction): ?TaskScheduler\TaskHistory { - $sortMap = [ - 'id' => 'periodic_task_history_id', - 'launchtime' => 'launch_time', - 'status' => 'status', - 'errors' => 'num_errors', - 'items' => 'num_items', - 'duration' => 'duration_ms' - ]; - - if (!isset($sortMap[$sort])) { - return null; - } - $sort = $sortMap[$sort]; - + public function taskHistory( + int $taskId, + int $limit, + int $offset, + ): ?TaskScheduler\TaskHistory { self::$db->prepared_query(" SELECT periodic_task_history_id, launch_time, status, num_errors, num_items, duration_ms FROM periodic_task_history WHERE periodic_task_id = ? - ORDER BY $sort $direction + ORDER BY launch_time DESC LIMIT ? OFFSET ? - ", $id, $limit, $offset); + ", $taskId, $limit, $offset + ); $items = self::$db->to_array('periodic_task_history_id', MYSQLI_ASSOC); $historyEvents = []; @@ -171,14 +165,17 @@ class TaskScheduler extends Base { } } - $task = new TaskScheduler\TaskHistory($this->getTask($id)['name'], $this->getTotal($id)); + $history = new TaskScheduler\TaskHistory( + $this->findById($taskId)['name'], + $this->taskRunTotal($taskId) + ); foreach ($items as $item) { [$historyId, $launchTime, $status, $numErrors, $numItems, $duration] = array_values($item); $taskEvents = $historyEvents[$historyId] ?? []; - $task->items[] = new TaskScheduler\HistoryItem($launchTime, $status, $numErrors, $numItems, $duration, $taskEvents); + $history->items[] = new TaskScheduler\HistoryItem($launchTime, $status, $numErrors, $numItems, $duration, $taskEvents); } - return $task; + return $history; } private function constructAxes(array $data, string $key, array $axes, bool $time): array { @@ -186,17 +183,17 @@ class TaskScheduler extends Base { foreach ($axes as $axis) { if (is_array($axis)) { - $id = $axis[0]; + $taskId = $axis[0]; $name = $axis[1]; } else { - $id = $axis; + $taskId = $axis; $name = $axis; } $result[] = [ 'name' => $name, 'data' => array_map( - fn($v) => [$time ? strtotime($v[$key]) * 1000 : $v[$key], (int)$v[$id]], + fn ($v) => [$time ? strtotime($v[$key]) * 1000 : $v[$key], (int)$v[$taskId]], $data ) ]; @@ -204,7 +201,7 @@ class TaskScheduler extends Base { return $result; } - public function getRuntimeStats(int $days = 90): array { + public function runtimeStats(int $days = 90): array { self::$db->prepared_query(" SELECT date_format(pth.launch_time, '%Y-%m-%d %H:00:00') AS date, sum(pth.duration_ms) AS duration, @@ -268,7 +265,7 @@ class TaskScheduler extends Base { ]; } - public function getTaskRuntimeStats(int $taskId, int $days = 90): array { + public function taskRuntimeStats(int $taskId, int $days = 90): array { self::$db->prepared_query(" SELECT date(pth.launch_time) AS date, sum(pth.duration_ms) AS duration, @@ -285,42 +282,41 @@ class TaskScheduler extends Base { return $this->constructAxes(self::$db->to_array(false, MYSQLI_ASSOC), 'date', ['duration', 'processed'], true); } - public function getTaskSnapshot(float $start, float $end): array { - self::$db->prepared_query(' - SELECT pt.periodic_task_id, pt.name, pth.launch_time, pth.status, pth.num_errors, pth.num_items, pth.duration_ms - FROM periodic_task pt - INNER JOIN periodic_task_history pth USING (periodic_task_id) - WHERE pth.launch_time <= ? AND pth.launch_time + INTERVAL pth.duration_ms / 1000 SECOND >= ? - ', $end, $start + public function runNow(int $taskId): int { + self::$db->prepared_query(" + UPDATE periodic_task SET + run_now = 1 - run_now + WHERE periodic_task_id = ? + ", $taskId ); - - return self::$db->to_array('periodic_task_id', MYSQLI_ASSOC); + return self::$db->affected_rows(); } - public function run(): void { + public function run(): int { $pendingMigrations = array_filter( json_decode( (string)shell_exec(BIN_PHINX . ' status -c ' . PHINX_MYSQL . ' --format=json | tail -n 1'), true )['migrations'], - fn($value) => count($value) > 0 && $value['migration_status'] === 'down' + fn ($value) => count($value) > 0 && $value['migration_status'] === 'down' ); if ($pendingMigrations) { - Irc::sendMessage(IRC_CHAN_DEV, 'Pending migrations found, scheduler cannot continue'); + Util\Irc::sendMessage(IRC_CHAN_DEV, 'Pending migrations found, scheduler cannot continue'); echo "Pending migrations found, aborting\n"; - return; + return 0; } /** * We attempt to run as many tasks as we can within a minute. If a task * runs over the TTL, it will be noted as in progress, so the next - * invocation of the scheduler will ignore it. When the task finally + * invocation of the scheduler ignores it. When the task finally * returns, this invocation will exit. * If a task fails, do not try to run again in this slice. */ $fail = [0]; + $run = 0; $TTL = microtime(true) + 58; while (microtime(true) < $TTL) { @@ -351,34 +347,37 @@ class TaskScheduler extends Base { ", ...$fail ); if (!$taskId) { + // no tasks remaining to be run break; } + $run++; $result = $this->runTask($taskId); if ($result == -1) { $fail[] = $taskId; } } + return $run; } public function runClass(string $className, bool $debug = false): int { - return $this->runTask( - (int)self::$db->scalar(" - SELECT pt.periodic_task_id FROM periodic_task pt WHERE pt.classname = ? - ", $className - ), $debug - ); - } - - public function runTask(int $id, bool $debug = false): int { - $task = $this->getTask($id); + $task = $this->findByName($className); if ($task === null) { return -1; } - echo('Running task ' . $task['name'] . "..."); + return $this->runTask($task['periodic_task_id'], $debug); + } - $taskRunner = $this->createRunner($id, $task['name'], $task['classname'], $task['is_debug'] || $debug); + public function runTask(int $taskId, bool $debug = false): int { + $task = $this->findById($taskId); + if ($task === null) { + return -1; + } + echo "Running task {$task['name']}..."; + + $taskRunner = $this->createRunner($taskId, $task['name'], $task['classname'], $task['is_debug'] || $debug); if ($taskRunner === null) { - Irc::sendMessage(IRC_CHAN_DEV, "Failed to construct task {$task['name']}"); + echo "DONE! (0.000)\n"; + Util\Irc::sendMessage(IRC_CHAN_DEV, "Failed to construct task {$task['name']}"); return -1; } @@ -409,18 +408,17 @@ class TaskScheduler extends Base { UPDATE periodic_task SET run_now = FALSE WHERE periodic_task_id = ? - ', $id + ', $taskId ); - $this->flush(); } return $processed; } - private function createRunner(int $id, string $name, string $class, bool $isDebug): mixed { + private function createRunner(int $taskId, string $name, string $class, bool $isDebug): mixed { $class = 'Gazelle\\Task\\' . $class; if (!class_exists($class)) { return null; } - return new $class($id, $name, $isDebug); + return new $class($taskId, $name, $isDebug); } } diff --git a/app/User/Activity.php b/app/User/Activity.php index 3f868233a..603b770fe 100644 --- a/app/User/Activity.php +++ b/app/User/Activity.php @@ -138,10 +138,12 @@ class Activity extends \Gazelle\BaseUser { if ($lastSchedulerRun > SCHEDULER_DELAY) { $this->setAlert("CRON"); } - $insane = $scheduler->getInsaneTasks(); + $insane = $scheduler->insaneTaskList(); if ($insane) { - $plural = plural($insane); - $this->setAlert("TASK"); + $this->setAlert("TASK" + ); } } return $this; diff --git a/sections/tools/development/periodic_alter.php b/sections/tools/development/periodic_alter.php index 1c99f8e13..6b1f464cb 100644 --- a/sections/tools/development/periodic_alter.php +++ b/sections/tools/development/periodic_alter.php @@ -9,17 +9,9 @@ if (!$Viewer->permitted('admin_periodic_task_manage')) { Error403::error(); } -authorize(); - -$scheduler = new TaskScheduler(); -$taskId = (int)($_POST['id'] ?? 0); -if ($_POST['submit'] == 'Delete') { - if (!$taskId) { - $err = 'Unknown or missing task id for delete'; - } else { - $scheduler->deleteTask($taskId); - } -} else { +$taskId = (int)($_POST['id'] ?? 0); +if ($taskId && $_POST['submit'] == 'Edit') { + authorize(); $validator = new Util\Validator(); $validator->setFields([ ['name', true, 'string', 'The name must be set, and has a max length of 64 characters', ['maxlength' => 64]], @@ -29,26 +21,14 @@ if ($_POST['submit'] == 'Delete') { ]); $err = $validator->validate($_POST) ? false : $validator->errorMessage(); if ($err === false) { - if ($_POST['submit'] == 'Create') { - if (!$scheduler::isClassValid($_POST['classname'])) { - $err = "Cannot import class " . $_POST['classname']; - } else { - $scheduler->createTask($_POST['name'], $_POST['classname'], $_POST['description'], intval($_POST['interval']), - isset($_POST['enabled']), isset($_POST['sane']), isset($_POST['debug']) - ); - } - } elseif ($_POST['submit'] == 'Edit') { - if (!$taskId) { - $err = 'Unknown or missing task id for edit'; - } - $task = $scheduler->getTask($taskId); - if ($task == null) { - $err = "Task $taskId not found"; - } - $scheduler->updateTask($taskId, $_POST['name'], $_POST['classname'], $_POST['description'], - (int)$_POST['interval'], isset($_POST['enabled']), isset($_POST['sane']), isset($_POST['debug']) - ); + $scheduler = new TaskScheduler(); + $task = $scheduler->findById($taskId); + if ($task == null) { + $err = "Task $taskId not found"; } + $scheduler->updateTask($taskId, $_POST['name'], $_POST['classname'], $_POST['description'], + (int)$_POST['interval'], isset($_POST['enabled']), isset($_POST['sane']), isset($_POST['debug']) + ); } } diff --git a/sections/tools/development/periodic_detail.php b/sections/tools/development/periodic_detail.php index 2432dc8df..a73b04d56 100644 --- a/sections/tools/development/periodic_detail.php +++ b/sections/tools/development/periodic_detail.php @@ -11,31 +11,23 @@ if (!$Viewer->permitted('admin_periodic_task_view')) { } $scheduler = new TaskScheduler(); -$id = (int)($_GET['id'] ?? 0); -if (!$scheduler->getTask($id)) { +$taskId = (int)($_GET['id'] ?? 0); +if (!$scheduler->findById($taskId)) { Error404::error(); } -$header = new Util\SortableTableHeader('launchtime', [ - 'id' => ['defaultSort' => 'desc'], - 'launchtime' => ['defaultSort' => 'desc', 'text' => 'Launch Time'], - 'duration' => ['defaultSort' => 'desc', 'text' => 'Duration'], - 'status' => ['defaultSort' => 'desc', 'text' => 'Status'], - 'items' => ['defaultSort' => 'desc', 'text' => 'Processed'], - 'errors' => ['defaultSort' => 'desc', 'text' => 'Errors'] -]); - $paginator = new Util\Paginator(ITEMS_PER_PAGE, (int)($_GET['page'] ?? 1)); -$paginator->setTotal($scheduler->getTotal($id)); - -$stats = $scheduler->getTaskRuntimeStats($id); +$paginator->setTotal($scheduler->taskRunTotal($taskId)); +$stats = $scheduler->taskRuntimeStats($taskId); echo $Twig->render('admin/scheduler/task.twig', [ - 'header' => $header, - 'stats' => $scheduler->getTaskRuntimeStats($id), + 'header' => $scheduler->heading(), + 'stats' => $stats, 'duration' => json_encode($stats[0]['data']), 'processed' => json_encode($stats[1]['data']), - 'task' => $scheduler->getTaskHistory($id, $paginator->limit(), $paginator->offset(), $header->orderKey(), $header->dir()), + 'task' => $scheduler->taskHistory( + $taskId, $paginator->limit(), $paginator->offset() + ), 'paginator' => $paginator, 'viewer' => $Viewer, ]); diff --git a/sections/tools/development/periodic_edit.php b/sections/tools/development/periodic_edit.php index 958fa5daf..c9274413d 100644 --- a/sections/tools/development/periodic_edit.php +++ b/sections/tools/development/periodic_edit.php @@ -12,6 +12,6 @@ if (!$Viewer->permitted('admin_periodic_task_manage')) { echo $Twig->render('admin/scheduler/edit.twig', [ 'err' => $err ?? null, - 'task_list' => (new TaskScheduler())->getTasks(), + 'task_list' => (new TaskScheduler())->taskList(), 'viewer' => $Viewer, ]); diff --git a/sections/tools/development/periodic_run.php b/sections/tools/development/periodic_run.php index 133e34784..698dc8efd 100644 --- a/sections/tools/development/periodic_run.php +++ b/sections/tools/development/periodic_run.php @@ -23,7 +23,7 @@ $processed = $scheduler->runTask($taskId, true); $output = ob_get_flush(); echo $Twig->render('admin/scheduler/run.twig', [ - 'task' => $scheduler->getTask($taskId), + 'task' => $scheduler->findById($taskId), 'output' => $output, 'processed' => $processed, ]); diff --git a/sections/tools/development/periodic_stats.php b/sections/tools/development/periodic_stats.php index 057f87265..bccb935ef 100644 --- a/sections/tools/development/periodic_stats.php +++ b/sections/tools/development/periodic_stats.php @@ -10,7 +10,7 @@ if (!$Viewer->permitted('admin_periodic_task_view')) { Error403::error(); } -$stats = (new TaskScheduler())->getRuntimeStats(); +$stats = (new TaskScheduler())->runtimeStats(); echo $Twig->render('admin/scheduler/stats.twig', [ 'hourly' => [ 'duration' => json_encode($stats['hourly'][0]['data']), diff --git a/sections/tools/development/periodic_view.php b/sections/tools/development/periodic_view.php index 79570be1d..feebd2c97 100644 --- a/sections/tools/development/periodic_view.php +++ b/sections/tools/development/periodic_view.php @@ -11,7 +11,7 @@ if (!$Viewer->permitted('admin_periodic_task_view')) { } $scheduler = new TaskScheduler(); -$taskId = (int)($_REQUEST['id'] ?? 0); +$taskId = (int)($_REQUEST['id'] ?? 0); if ($taskId && $_REQUEST['mode'] === 'run_now') { if (!$Viewer->permitted('admin_schedule')) { @@ -22,6 +22,7 @@ if ($taskId && $_REQUEST['mode'] === 'run_now') { } echo $Twig->render('admin/scheduler/view.twig', [ - 'task_list' => $scheduler->getTaskDetails(), - 'viewer' => $Viewer, + 'heading' => $scheduler->heading(), + 'task_list' => $scheduler->taskDetailList(), + 'viewer' => $Viewer, ]); diff --git a/templates/admin/scheduler/edit.twig b/templates/admin/scheduler/edit.twig index f324df84c..151cd843e 100644 --- a/templates/admin/scheduler/edit.twig +++ b/templates/admin/scheduler/edit.twig @@ -48,44 +48,9 @@ - {% endfor %} - - Create Task - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
- {{ footer() }} diff --git a/templates/admin/scheduler/view.twig b/templates/admin/scheduler/view.twig index 664943e03..bbf340adf 100644 --- a/templates/admin/scheduler/view.twig +++ b/templates/admin/scheduler/view.twig @@ -3,52 +3,64 @@

Scheduler › Status

{% include 'admin/scheduler/links.twig' with {'can_edit': viewer.permitted('admin_periodic_task_manage')} only %} +
+Note: tasks are created and removed via Phinx migrations. When removing a task, remember to +remove the code as well as the database entry.
+
+
+Toggle +date display between relative and absolute. +
+
- - - - - - - - - - + + + + + + + + + + {% for t in task_list %} - {% set color = '' %} - {% set prefix = '' %} - {% if not t.is_sane %} - {% set color = 'color:tomato;' %} - {% set prefix = 'Insane: ' %} - {% endif %} - {% if not t.is_enabled and not t.run_now %} - {% set color = 'color:sandybrown;' %} - {% set prefix = prefix ~ 'Disabled: ' %} - {% endif %} - {% if t.run_now %} - {% set color = 'color:green;' %} - {% set prefix = prefix ~ 'Run Now: ' %} - {% endif %} +{% set color = '' %} +{% set prefix = '' %} +{% if not t.is_sane %} +{% set color = 'color:tomato;' %} +{% set prefix = '😵 ' %} +{% endif %} +{% if not t.is_enabled and not t.run_now %} +{% set color = 'color:sandybrown;' %} +{% set prefix = prefix ~ '❌ ' %} +{% endif %} +{% if t.run_now %} +{% set color = 'color:green;' %} +{% set prefix = prefix ~ 'Run Now: ' %} +{% endif %} - @@ -56,10 +68,12 @@ {% endfor %} diff --git a/tests/phpunit/SchedulerTest.php b/tests/phpunit/SchedulerTest.php index 52de3292b..9dc4b4453 100644 --- a/tests/phpunit/SchedulerTest.php +++ b/tests/phpunit/SchedulerTest.php @@ -4,13 +4,14 @@ namespace Gazelle; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\DataProvider; +use GazelleUnitTest\Helper; // phpcs:disable PSR1.Files.SideEffects.FoundWithSymbols ini_set('memory_limit', '1G'); // phpcs:enable class SchedulerTest extends TestCase { - public function testRun(): void { + public function testGlobalRun(): void { $scheduler = new TaskScheduler(); $this->expectOutputRegex('/^(?:\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \[(?:debug|info)\] (.*?)\n|Running task (?:.*?)\.\.\.DONE! \(\d+\.\d+\)\n)*$/'); $scheduler->run(); @@ -42,7 +43,15 @@ class SchedulerTest extends TestCase { public function testMissingTaskEntry(): void { $scheduler = new TaskScheduler(); - $this->assertEquals(-1, $scheduler->runClass("NoSuchClassname"), "sched-task-no-such-class"); + $this->assertEquals( + -1, + $scheduler->runClass("NoSuchClassname"), + "sched-task-no-such-class" + ); + $this->assertFalse( + $scheduler->isClassValid("NoSuchClassname"), + "sched-is-class-valid" + ); } public function testMissingImplementation(): void { @@ -60,7 +69,13 @@ class SchedulerTest extends TestCase { ", $name, "phpunit task", "A task with no PHP implementation" ); - $this->assertEquals(-1, $scheduler->runClass($name), "sched-task-unimplemented"); + ob_start(); + $this->assertEquals( + -1, + $scheduler->runClass($name), + "sched-task-unimplemented" + ); + ob_end_clean(); $db->prepared_query(" DELETE FROM periodic_task WHERE classname = ? ", $name @@ -70,6 +85,10 @@ class SchedulerTest extends TestCase { #[DataProvider('taskProvider')] public function testTask(string $taskName): void { $scheduler = new TaskScheduler(); + $this->assertTrue( + $scheduler->isClassValid($taskName), + "sched-has-task-$taskName" + ); ob_start(); $this->assertGreaterThanOrEqual( -1, @@ -80,15 +99,13 @@ class SchedulerTest extends TestCase { } public static function taskProvider(): array { - $taskList = [ - ['NoSuchTaskEither'], + return [ ['ArtistUsage'], ['BetterTranscode'], ['CalculateContestLeaderboard'], ['CommunityStats'], ['CycleAuthKeys'], ['DeleteTags'], - ['DemoteUsers'], ['DemoteUsersRatio'], ['DisableDownloadingRatioWatch'], ['DisableLeechingRatioWatch'], @@ -104,14 +121,12 @@ class SchedulerTest extends TestCase { ['InactiveUserDeactivate'], ['LockOldThreads'], ['LowerLoginAttempts'], - ['NotifyNonseedingUploaders'], ['Peerupdate'], ['PromoteUsers'], ['PurgeOldTaskHistory'], ['RatioRequirements'], ['RatioWatch'], ['RemoveDeadSessions'], - ['RemoveExpiredWarnings'], ['ResolveStaffPms'], ['SSLCertificate'], ['Test'], @@ -119,20 +134,79 @@ class SchedulerTest extends TestCase { ['UpdateDailyTop10'], ['UpdateSeedTimes'], ['UpdateUserBonusPoints'], - ['UpdateUserTorrentHistory'], ['UpdateWeeklyTop10'], ['UserLastAccess'], ['UserStatsDaily'], ['UserStatsMonthly'], ['UserStatsYearly'], ]; + } - if (getenv('CI') !== false) { - // too dangerous to run locally - $taskList[] = ['DeleteNeverSeededTorrents']; - $taskList[] = ['DeleteUnseededTorrents']; - } + public function testTaskDetailList(): void { + $list = new TaskScheduler()->taskDetailList(); + $this->assertCount(44, $list, 'task-detail-list'); + $detail = current($list); + $this->assertEquals( + [ + "periodic_task_id", "name", "description", "period", + "is_enabled", "is_sane", "run_now", "runs", "processed", + "errors", "events", "duration", "status", "last_run", + "next_run", + ], + array_keys($detail), + 'task-detail-entry' + ); + } - return $taskList; + public function testTaskHistory(): void { + $scheduler = new TaskScheduler(); + $task = $scheduler->findByName('Test'); + $taskId = $task['periodic_task_id']; + $initial = $scheduler->taskHistory($taskId, 10, 0); + $total = $scheduler->taskRunTotal($taskId); + ob_start(); + $scheduler->runTask($taskId); + ob_end_clean(); + $this->assertEquals( + $total + 1, + $scheduler->taskRunTotal($taskId), + 'task-run-total' + ); + + $history = $scheduler->taskHistory($taskId, 10, 0); + $this->assertEquals( + 1, + $history->count - $initial->count, + 'task-history-total' + ); + $item = current($history->items); + $this->assertTrue(Helper::recentDate($item->launchTime), 'task-launch-time'); + $this->assertEquals('completed', $item->status, 'task-status'); + $this->assertEquals(0, $item->nrErrors, 'task-nr-error'); + $this->assertEquals(0, $item->nrItems, 'task-nr-item'); + } + + public function testTaskStats(): void { + $scheduler = new TaskScheduler(); + $task = $scheduler->findByName('Test'); + $taskId = $task['periodic_task_id']; + $stats = $scheduler->taskRuntimeStats($taskId, 1); + $this->assertCount(2, $stats, 'task-runtime-stats-count'); + $this->assertEquals( + 'duration', $stats[0]['name'], 'task-stats-duration', + ); + $this->assertEquals( + 'processed', $stats[1]['name'], 'task-stats-processed', + ); + } + + public function testGlobalStats(): void { + $scheduler = new TaskScheduler(); + $stats = $scheduler->runtimeStats(); + $this->assertEquals( + ['hourly', 'daily', 'tasks', 'totals'], + array_keys($stats), + 'task-stats-global', + ); } }
NameIntervalLast Run ToggleDurationNext RunStatusRunsProcessedErrorsEvents{{ heading.emit('name')|raw }}{{ heading.emit('period')|raw }}{{ heading.emit('last')|raw }}{{ heading.emit('duration')|raw }}{{ heading.emit('next')|raw }}{{ heading.emit('status')|raw }}{{ heading.emit('runs')|raw }}{{ heading.emit('processed')|raw }}{{ heading.emit('errors')|raw }}{{ heading.emit('events')|raw }}
- {{ prefix }}{{ t.name }} + + {{ prefix }}{{ t.name }} {{ t.period|time_compact }} - {% if t.last_run %}{{ t.last_run|time_diff }}{% else %}Never{% endif %} + {% if t.last_run %}{{ t.last_run|time_diff + }}{% else %}Never{% endif %} {{ t.duration }}ms - {% if not t.is_enabled %} +{% if not t.is_enabled %} Never - {% else %} +{% else %} {{ t.next_run|time_diff }} - {% endif %} +{% endif %} {{ t.status|default('-') }} {{ t.runs|number_format }}{{ t.errors|number_format }} {{ t.events|number_format }} - {% if viewer.permitted('admin_schedule') %} - Enqueue - Run - {% endif %} +{% if viewer.permitted('admin_schedule') %} + Enqueue + Run +{% endif %}