Initial Upload

This commit is contained in:
Matt Batchelder
2025-12-02 10:32:59 -05:00
commit 05ce0da296
2240 changed files with 467811 additions and 0 deletions

621
lib/Controller/Task.php Normal file
View File

@@ -0,0 +1,621 @@
<?php
/*
* Copyright (C) 2025 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\Controller;
use Carbon\Carbon;
use Cron\CronExpression;
use Illuminate\Support\Str;
use Psr\Container\ContainerInterface;
use Slim\Http\Response as Response;
use Slim\Http\ServerRequest as Request;
use Stash\Interfaces\PoolInterface;
use Xibo\Factory\TaskFactory;
use Xibo\Helper\DateFormatHelper;
use Xibo\Storage\StorageServiceInterface;
use Xibo\Storage\TimeSeriesStoreInterface;
use Xibo\Support\Exception\NotFoundException;
use Xibo\XTR\TaskInterface;
/**
* Class Task
* @package Xibo\Controller
*/
class Task extends Base
{
/** @var TaskFactory */
private $taskFactory;
/** @var StorageServiceInterface */
private $store;
/** @var TimeSeriesStoreInterface */
private $timeSeriesStore;
/** @var PoolInterface */
private $pool;
/** ContainerInterface */
private $container;
/**
* Set common dependencies.
* @param StorageServiceInterface $store
* @param TimeSeriesStoreInterface $timeSeriesStore
* @param PoolInterface $pool
* @param TaskFactory $taskFactory
* @param ContainerInterface $container
*/
public function __construct($store, $timeSeriesStore, $pool, $taskFactory, ContainerInterface $container)
{
$this->taskFactory = $taskFactory;
$this->store = $store;
$this->timeSeriesStore = $timeSeriesStore;
$this->pool = $pool;
$this->container = $container;
}
/**
* Display Page
* @param Request $request
* @param Response $response
* @return \Psr\Http\Message\ResponseInterface|Response
* @throws \Xibo\Support\Exception\ControllerNotImplemented
* @throws \Xibo\Support\Exception\GeneralException
*/
public function displayPage(Request $request, Response $response)
{
$this->getState()->template = 'task-page';
return $this->render($request, $response);
}
/**
* Grid
* @param Request $request
* @param Response $response
* @return \Psr\Http\Message\ResponseInterface|Response
* @throws \Xibo\Support\Exception\ControllerNotImplemented
* @throws \Xibo\Support\Exception\GeneralException
*/
public function grid(Request $request, Response $response)
{
$sanitizedParams = $this->getSanitizer($request->getParams());
$tasks = $this->taskFactory->query(
$this->gridRenderSort($sanitizedParams),
$this->gridRenderFilter([], $sanitizedParams)
);
foreach ($tasks as $task) {
/** @var \Xibo\Entity\Task $task */
$task->setUnmatchedProperty('nextRunDt', $task->nextRunDate());
if ($this->isApi($request)) {
continue;
}
$task->includeProperty('buttons');
$task->buttons[] = array(
'id' => 'task_button_run.now',
'url' => $this->urlFor($request, 'task.runNow.form', ['id' => $task->taskId]),
'text' => __('Run Now'),
'dataAttributes' => [
['name' => 'auto-submit', 'value' => true],
[
'name' => 'commit-url',
'value' => $this->urlFor($request, 'task.runNow', ['id' => $task->taskId]),
],
['name' => 'commit-method', 'value' => 'POST']
]
);
// Don't show any edit buttons if the config is locked.
if ($this->getConfig()->getSetting('TASK_CONFIG_LOCKED_CHECKB') == 1
|| $this->getConfig()->getSetting('TASK_CONFIG_LOCKED_CHECKB') == 'Checked'
) {
continue;
}
// Edit Button
$task->buttons[] = array(
'id' => 'task_button_edit',
'url' => $this->urlFor($request, 'task.edit.form', ['id' => $task->taskId]),
'text' => __('Edit')
);
// Delete Button
$task->buttons[] = array(
'id' => 'task_button_delete',
'url' => $this->urlFor($request, 'task.delete.form', ['id' => $task->taskId]),
'text' => __('Delete')
);
}
$this->getState()->template = 'grid';
$this->getState()->recordsTotal = $this->taskFactory->countLast();
$this->getState()->setData($tasks);
return $this->render($request, $response);
}
/**
* Add form
* @param Request $request
* @param Response $response
* @return \Psr\Http\Message\ResponseInterface|Response
* @throws \Xibo\Support\Exception\ControllerNotImplemented
* @throws \Xibo\Support\Exception\GeneralException
*/
public function addForm(Request $request, Response $response)
{
// Provide a list of possible task classes by searching for .task file in /tasks and /custom
$data = ['tasksAvailable' => []];
// Do we have any modules to install?!
if ($this->getConfig()->getSetting('TASK_CONFIG_LOCKED_CHECKB') != 1 && $this->getConfig()->getSetting('TASK_CONFIG_LOCKED_CHECKB') != 'Checked') {
// Get a list of matching files in the modules folder
$files = array_merge(glob(PROJECT_ROOT . '/tasks/*.task'), glob(PROJECT_ROOT . '/custom/*.task'));
// Add to the list of available tasks
foreach ($files as $file) {
$config = json_decode(file_get_contents($file));
$config->file = Str::replaceFirst(PROJECT_ROOT, '', $file);
$data['tasksAvailable'][] = $config;
}
}
$this->getState()->template = 'task-form-add';
$this->getState()->setData($data);
return $this->render($request, $response);
}
/**
* Add
* @param Request $request
* @param Response $response
* @return \Psr\Http\Message\ResponseInterface|Response
* @throws NotFoundException
* @throws \Xibo\Support\Exception\ControllerNotImplemented
* @throws \Xibo\Support\Exception\GeneralException
* @throws \Xibo\Support\Exception\InvalidArgumentException
*/
public function add(Request $request, Response $response)
{
$sanitizedParams = $this->getSanitizer($request->getParams());
$task = $this->taskFactory->create();
$task->name = $sanitizedParams->getString('name');
$task->configFile = $sanitizedParams->getString('file');
$task->schedule = $sanitizedParams->getString('schedule');
$task->status = \Xibo\Entity\Task::$STATUS_IDLE;
$task->lastRunStatus = 0;
$task->isActive = 0;
$task->runNow = 0;
$task->setClassAndOptions();
$task->save();
// Return
$this->getState()->hydrate([
'httpStatus' => 201,
'message' => sprintf(__('Added %s'), $task->name),
'id' => $task->taskId,
'data' => $task
]);
return $this->render($request, $response);
}
/**
* Edit Form
* @param Request $request
* @param Response $response
* @param $id
* @return \Psr\Http\Message\ResponseInterface|Response
* @throws NotFoundException
* @throws \Xibo\Support\Exception\ControllerNotImplemented
* @throws \Xibo\Support\Exception\GeneralException
*/
public function editForm(Request $request, Response $response, $id)
{
$task = $this->taskFactory->getById($id);
$task->setClassAndOptions();
$this->getState()->template = 'task-form-edit';
$this->getState()->setData([
'task' => $task
]);
return $this->render($request, $response);
}
/**
* @param Request $request
* @param Response $response
* @param $id
* @return \Psr\Http\Message\ResponseInterface|Response
* @throws NotFoundException
* @throws \Xibo\Support\Exception\ControllerNotImplemented
* @throws \Xibo\Support\Exception\GeneralException
* @throws \Xibo\Support\Exception\InvalidArgumentException
*/
public function edit(Request $request, Response $response, $id)
{
$task = $this->taskFactory->getById($id);
$sanitizedParams = $this->getSanitizer($request->getParams());
$task->setClassAndOptions();
$task->name = $sanitizedParams->getString('name');
$task->schedule = $sanitizedParams->getString('schedule');
$task->isActive = $sanitizedParams->getCheckbox('isActive');
// Loop through each option and see if a new value is provided
foreach ($task->options as $option => $value) {
$provided = $sanitizedParams->getString($option);
if ($provided !== null) {
$this->getLog()->debug('Setting ' . $option . ' to ' . $provided);
$task->options[$option] = $provided;
}
}
$this->getLog()->debug('New options = ' . var_export($task->options, true));
$task->save();
// Return
$this->getState()->hydrate([
'httpStatus' => 200,
'message' => sprintf(__('Edited %s'), $task->name),
'id' => $task->taskId,
'data' => $task
]);
return $this->render($request, $response);
}
/**
* Delete Form
* @param Request $request
* @param Response $response
* @param $id
* @return \Psr\Http\Message\ResponseInterface|Response
* @throws NotFoundException
* @throws \Xibo\Support\Exception\ControllerNotImplemented
* @throws \Xibo\Support\Exception\GeneralException
*/
public function deleteForm(Request $request, Response $response, $id)
{
$task = $this->taskFactory->getById($id);
$this->getState()->template = 'task-form-delete';
$this->getState()->setData([
'task' => $task
]);
return $this->render($request, $response);
}
/**
* @param Request $request
* @param Response $response
* @param $id
* @return \Psr\Http\Message\ResponseInterface|Response
* @throws NotFoundException
* @throws \Xibo\Support\Exception\ControllerNotImplemented
* @throws \Xibo\Support\Exception\GeneralException
*/
public function delete(Request $request, Response $response, $id)
{
$task = $this->taskFactory->getById($id);
$task->delete();
// Return
$this->getState()->hydrate([
'httpStatus' => 204,
'message' => sprintf(__('Deleted %s'), $task->name)
]);
return $this->render($request, $response);
}
/**
* Delete Form
* @param Request $request
* @param Response $response
* @param $id
* @return \Psr\Http\Message\ResponseInterface|Response
* @throws NotFoundException
* @throws \Xibo\Support\Exception\ControllerNotImplemented
* @throws \Xibo\Support\Exception\GeneralException
*/
public function runNowForm(Request $request, Response $response, $id)
{
$task = $this->taskFactory->getById($id);
$this->getState()->template = 'task-form-run-now';
$this->getState()->autoSubmit = $this->getAutoSubmit('taskRunNowForm');
$this->getState()->setData([
'task' => $task
]);
return $this->render($request, $response);
}
/**
* @param Request $request
* @param Response $response
* @param $id
* @return \Psr\Http\Message\ResponseInterface|Response
* @throws NotFoundException
* @throws \Xibo\Support\Exception\ControllerNotImplemented
* @throws \Xibo\Support\Exception\GeneralException
* @throws \Xibo\Support\Exception\InvalidArgumentException
*/
public function runNow(Request $request, Response $response, $id)
{
$task = $this->taskFactory->getById($id);
$task->runNow = 1;
$task->save();
// Return
$this->getState()->hydrate([
'httpStatus' => 204,
'message' => sprintf(__('Run Now set on %s'), $task->name)
]);
return $this->render($request, $response);
}
/**
* @param Request $request
* @param Response $response
* @param $id
* @return \Psr\Http\Message\ResponseInterface|Response
* @throws NotFoundException
* @throws \Xibo\Support\Exception\ControllerNotImplemented
* @throws \Xibo\Support\Exception\GeneralException
* @throws \Xibo\Support\Exception\InvalidArgumentException
*/
public function run(Request $request, Response $response, $id)
{
// Get this task
if (is_numeric($id)) {
$task = $this->taskFactory->getById($id);
} else {
$task = $this->taskFactory->getByName($id);
}
// Set to running
$this->getLog()->debug('run: Running Task ' . $task->name
. ' [' . $task->taskId . '], Class = ' . $task->class);
// Run
$task->setStarted();
try {
// Instantiate
if (!class_exists($task->class)) {
throw new NotFoundException(sprintf(__('Task with class name %s not found'), $task->class));
}
/** @var TaskInterface $taskClass */
$taskClass = new $task->class();
// Record the start time
$start = Carbon::now()->format('U');
$taskClass
->setSanitizer($this->getSanitizer($request->getParams()))
->setUser($this->getUser())
->setConfig($this->getConfig())
->setLogger($this->getLog())
->setPool($this->pool)
->setStore($this->store)
->setTimeSeriesStore($this->timeSeriesStore)
->setDispatcher($this->getDispatcher())
->setFactories($this->container)
->setTask($task)
->run();
// We should commit anything this task has done
$this->store->commitIfNecessary();
// Collect results
$task->lastRunDuration = Carbon::now()->format('U') - $start;
$task->lastRunMessage = $taskClass->getRunMessage();
$task->lastRunStatus = \Xibo\Entity\Task::$STATUS_SUCCESS;
$task->lastRunExitCode = 0;
} catch (\Exception $e) {
$this->getLog()->error('run: ' . $e->getMessage() . ' Exception Type: ' . get_class($e));
$this->getLog()->debug($e->getTraceAsString());
// We should roll back anything we've done so far
if ($this->store->getConnection()->inTransaction()) {
$this->store->getConnection()->rollBack();
}
// Set the results to error
$task->lastRunMessage = $e->getMessage();
$task->lastRunStatus = \Xibo\Entity\Task::$STATUS_ERROR;
$task->lastRunExitCode = 1;
}
$task->lastRunDt = Carbon::now()->format('U');
$task->runNow = 0;
$task->status = \Xibo\Entity\Task::$STATUS_IDLE;
// lastRunMessage columns has a limit of 254 characters, if the message is longer, we need to truncate it.
if (strlen($task->lastRunMessage) >= 255) {
$task->lastRunMessage = substr($task->lastRunMessage, 0, 249) . '(...)';
}
// Finished
$task->setFinished();
$this->getLog()->debug('run: Finished Task ' . $task->name . ' [' . $task->taskId . '] Run Dt: '
. Carbon::now()->format(DateFormatHelper::getSystemFormat()));
$this->setNoOutput();
return $this->render($request, $response);
}
/**
* Poll for tasks to run
* continue polling until there aren't anymore to run
* allow for multiple polls to run at the same time
* @param Request $request
* @param Response $response
* @return \Psr\Http\Message\ResponseInterface|Response
* @throws \Xibo\Support\Exception\ControllerNotImplemented
* @throws \Xibo\Support\Exception\GeneralException
*/
public function poll(Request $request, Response $response)
{
$this->getLog()->debug('poll: XTR poll started');
// Process timeouts
$this->pollProcessTimeouts();
// Keep track of tasks we've run during this poll period
// we will use this as a catch-all so that we do not run a task more than once.
$tasksRun = [];
// We loop until we have gone through without running a task
// each loop we are expecting to run ONE task only, to allow for multiple runs of XTR at the
// same time.
while (true) {
// Get tasks that aren't running currently
// we have to get them all here because we can't calculate the CRON schedule with SQL,
// therefore we return them all and process one and a time.
$tasks = $this->store->select('
SELECT taskId, `schedule`, runNow, lastRunDt
FROM `task`
WHERE isActive = 1
AND `status` <> :status
ORDER BY lastRunDuration
', ['status' => \Xibo\Entity\Task::$STATUS_RUNNING], 'xtr', true);
// Assume we won't run anything
$taskRun = false;
foreach ($tasks as $task) {
/** @var \Xibo\Entity\Task $task */
$taskId = $task['taskId'];
// Skip tasks that have already been run
if (in_array($taskId, $tasksRun)) {
continue;
}
try {
$cron = new CronExpression($task['schedule']);
} catch (\Exception $e) {
$this->getLog()->info('run: CRON syntax error for taskId ' . $taskId
. ', e: ' . $e->getMessage());
// Try and take the first X characters instead.
try {
$cron = new CronExpression(substr($task['schedule'], 0, strlen($task['schedule']) - 2));
} catch (\Exception) {
$this->getLog()->error('run: cannot fix CRON syntax error ' . $taskId);
continue;
}
}
// Is the next run date of this event earlier than now, or is the task set to runNow
$nextRunDt = $cron->getNextRunDate(\DateTime::createFromFormat('U', $task['lastRunDt']))
->format('U');
if ($task['runNow'] == 1 || $nextRunDt <= Carbon::now()->format('U')) {
$this->getLog()->info('poll: Running Task ' . $taskId);
try {
// Pass to run.
$this->run($request, $response, $taskId);
} catch (\Exception $exception) {
// The only thing which can fail inside run is core code,
// so it is reasonable here to disable the task.
$this->getLog()->error('poll: Task run error for taskId ' . $taskId
. '. E = ' . $exception->getMessage());
$this->getLog()->debug($exception->getTraceAsString());
// Set to error and disable.
$this->store->update('
UPDATE `task` SET status = :status, isActive = :isActive, lastRunMessage = :lastRunMessage
WHERE taskId = :taskId
', [
'taskId' => $taskId,
'status' => \Xibo\Entity\Task::$STATUS_ERROR,
'isActive' => 0,
'lastRunMessage' => 'Fatal Error: ' . $exception->getMessage()
], 'xtr', true, false);
}
// We have run a task
$taskRun = true;
// We've run this task during this polling period
$tasksRun[] = $taskId;
// As mentioned above, we only run 1 task at a time to allow for concurrent runs of XTR.
break;
}
}
// If we haven't run a task, then stop
if (!$taskRun) {
break;
}
}
$this->getLog()->debug('XTR poll stopped');
$this->setNoOutput();
return $this->render($request, $response);
}
private function pollProcessTimeouts()
{
$count = $this->store->update('
UPDATE `task` SET `status` = :newStatus
WHERE `isActive` = 1
AND `status` = :currentStatus
AND `lastRunStartDt` < :timeout
', [
'timeout' => Carbon::now()->subHours(12)->format('U'),
'currentStatus' => \Xibo\Entity\Task::$STATUS_RUNNING,
'newStatus' => \Xibo\Entity\Task::$STATUS_TIMEOUT,
], 'xtr', false, false);
if ($count > 0) {
$this->getLog()->error($count . ' timed out tasks.');
} else {
$this->getLog()->debug('No timed out tasks.');
}
}
}