diff --git a/config/app.example.php b/config/app.example.php index 1c4585a1..c3e0b2ca 100644 --- a/config/app.example.php +++ b/config/app.example.php @@ -80,11 +80,22 @@ 'ignoredTasks' => [], // per-task configuration overrides (timeout, retries, rate, costs, unique) - // 'tasks' => [ - // 'Queue.ProgressExample' => [ - // 'timeout' => 300, - // ], - // ], + 'tasks' => [ + //'Queue.ProgressExample' => [ + // 'timeout' => 300, + //], + ], + + // Admin dashboard settings + + // Layout for admin pages: + // - null (default): Uses 'Queue.queue' isolated Bootstrap 5 layout + // - false: Disables plugin layout, uses app's default layout + // - string: Uses specified layout + 'adminLayout' => null, + + // auto-refresh dashboard in seconds (0 = disabled) + 'dashboardAutoRefresh' => 0, ], 'Icon' => [ 'sets' => [ diff --git a/docs/README.md b/docs/README.md index f2f3e184..8624e564 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ * [Custom Tasks](sections/custom_tasks.md) ## Detailed documentation +* [Admin Dashboard](sections/admin_dashboard.md) [**NEW**] * [Configuration](sections/configuration.md) * [Cron](sections/cron.md) for Cronjob management * [Real-Time Progress](sections/realtime_progress.md) with Mercure/FrankenPHP diff --git a/docs/sections/admin_dashboard.md b/docs/sections/admin_dashboard.md new file mode 100644 index 00000000..f1a3c1fc --- /dev/null +++ b/docs/sections/admin_dashboard.md @@ -0,0 +1,111 @@ +# Admin Dashboard + +The Queue plugin includes a modern, self-contained admin dashboard for managing your queued jobs. The dashboard is completely isolated from your application's CSS/JS, using Bootstrap 5 and Font Awesome 6 via CDN. + +## Features + +- **Statistics Overview**: Real-time counts of pending, scheduled, running, and failed jobs +- **Status Banner**: Visual indicator showing whether the queue is running or idle +- **Job Management**: View, reset, and remove jobs directly from the dashboard +- **Worker Management**: Monitor active workers and terminate them if needed +- **Process History**: Track all queue processes with pagination +- **Trigger Jobs**: Manually add jobs that implement `AddFromBackendInterface` +- **Configuration View**: See current runtime configuration at a glance + +## Layout Configuration + +By default, the admin dashboard uses the isolated Bootstrap 5 layout (`Queue.queue`). This ensures the dashboard works independently of your application's styles. + +### Configuration Options + +```php +'Queue' => [ + // Layout for admin pages: + // - null (default): Uses 'Queue.queue' isolated Bootstrap 5 layout + // - false: Disables plugin layout, uses app's default layout + // - string: Uses specified layout + 'adminLayout' => null, + + // Auto-refresh dashboard every N seconds (0 = disabled) + 'dashboardAutoRefresh' => 30, +], +``` + +### Using Your Application's Layout + +To use your application's default layout instead of the isolated Bootstrap 5 layout: + +```php +'Queue' => [ + 'adminLayout' => false, +], +``` + +## Accessing the Dashboard + +Navigate to `/admin/queue` to access the dashboard. The main pages are: + +- `/admin/queue` - Dashboard with overview statistics +- `/admin/queue/processes` - Active workers management +- `/admin/queued-jobs` - Full job listing with search +- `/admin/queue-processes` - Process history + +## Customization + +### Overriding Templates + +You can override any template by creating the same file structure in your application's `templates/plugin/Queue/` directory: + +``` +templates/ +└── plugin/ + └── Queue/ + ├── layout/ + │ └── queue.php + └── Admin/ + └── Queue/ + └── index.php +``` + +### Custom Elements + +The dashboard uses several reusable elements that you can override: + +- `Queue.Queue/sidebar` - Sidebar navigation +- `Queue.Queue/stats_card` - Statistics cards +- `Queue.Queue/status_badge` - Job status badges +- `Queue.flash/success` - Success flash messages +- `Queue.flash/error` - Error flash messages +- `Queue.flash/warning` - Warning flash messages +- `Queue.flash/info` - Info flash messages + +### CSS Variables + +The isolated layout uses CSS variables that you can override: + +```css +:root { + --queue-primary: #0d6efd; + --queue-success: #198754; + --queue-warning: #ffc107; + --queue-danger: #dc3545; + --queue-info: #0dcaf0; + --queue-secondary: #6c757d; + --queue-dark: #212529; + --queue-light: #f8f9fa; + --queue-sidebar-bg: linear-gradient(135deg, #2c3e50 0%, #1a252f 100%); + --queue-sidebar-width: 260px; +} +``` + +## Screenshots + +The dashboard provides: + +1. **Status Banner** - Shows queue status (Running/Idle) with last activity timestamp +2. **Stats Cards** - Quick overview of job counts by status +3. **Pending Jobs Table** - List of pending/running jobs with inline actions +4. **Scheduled Jobs** - Jobs scheduled for future execution +5. **Statistics** - Aggregated statistics for completed jobs +6. **Trigger Jobs** - Buttons to manually trigger addable jobs +7. **Configuration** - Current runtime configuration display diff --git a/src/Controller/Admin/QueueAppController.php b/src/Controller/Admin/QueueAppController.php new file mode 100644 index 00000000..0c253e98 --- /dev/null +++ b/src/Controller/Admin/QueueAppController.php @@ -0,0 +1,39 @@ +loadComponent('Flash'); + + $this->loadHelpers(); + + // Layout configuration: + // - null (default): Uses 'Queue.queue' isolated Bootstrap 5 layout + // - false: Disables plugin layout, uses app's default layout + // - string: Uses specified layout (e.g., 'Queue.queue' or custom) + $layout = Configure::read('Queue.adminLayout'); + if ($layout !== false) { + $this->viewBuilder()->setLayout($layout ?: 'Queue.queue'); + } + } + +} diff --git a/src/Controller/Admin/QueueController.php b/src/Controller/Admin/QueueController.php index 170a27f7..d00a839f 100644 --- a/src/Controller/Admin/QueueController.php +++ b/src/Controller/Admin/QueueController.php @@ -3,8 +3,8 @@ namespace Queue\Controller\Admin; -use App\Controller\AppController; use Cake\Core\App; +use Cake\Core\Configure; use Cake\Http\Exception\NotFoundException; use Queue\Queue\AddFromBackendInterface; use Queue\Queue\AddInterface; @@ -14,24 +14,13 @@ * @property \Queue\Model\Table\QueuedJobsTable $QueuedJobs * @property \Queue\Model\Table\QueueProcessesTable $QueueProcesses */ -class QueueController extends AppController { - - use LoadHelperTrait; +class QueueController extends QueueAppController { /** * @var string|null */ protected ?string $defaultTable = 'Queue.QueuedJobs'; - /** - * @return void - */ - public function initialize(): void { - parent::initialize(); - - $this->loadHelpers(); - } - /** * Admin center. * Manage queues from admin backend (without the need to open ssh console window). @@ -61,7 +50,44 @@ public function index() { $addableTasks = $taskFinder->allAddable(AddFromBackendInterface::class); $servers = $QueueProcesses->serverList(); - $this->set(compact('new', 'current', 'data', 'pendingDetails', 'scheduledDetails', 'status', 'tasks', 'addableTasks', 'servers')); + $workers = $status ? $status['workers'] : 0; + + $scheduledJobs = count($scheduledDetails); + $runningJobs = $this->QueuedJobs->find() + ->where([ + 'completed IS' => null, + 'fetched IS NOT' => null, + 'failure_message IS' => null, + ]) + ->count(); + $failedJobs = $this->QueuedJobs->find() + ->where([ + 'completed IS' => null, + 'failure_message IS NOT' => null, + ]) + ->count(); + // Pending = total pending minus running and failed (to avoid double counting) + $pendingJobs = max(0, count($pendingDetails) - $runningJobs - $failedJobs); + + $configurations = (array)Configure::read('Queue'); + + $this->set(compact( + 'new', + 'current', + 'data', + 'pendingDetails', + 'scheduledDetails', + 'status', + 'tasks', + 'addableTasks', + 'servers', + 'workers', + 'pendingJobs', + 'scheduledJobs', + 'runningJobs', + 'failedJobs', + 'configurations', + )); } /** diff --git a/src/Controller/Admin/QueueProcessesController.php b/src/Controller/Admin/QueueProcessesController.php index bef5b0da..9c2d3cc5 100644 --- a/src/Controller/Admin/QueueProcessesController.php +++ b/src/Controller/Admin/QueueProcessesController.php @@ -3,7 +3,6 @@ namespace Queue\Controller\Admin; -use App\Controller\AppController; use Cake\Core\Configure; use Exception; use const SIGTERM; @@ -13,9 +12,7 @@ * @method \Cake\Datasource\ResultSetInterface<\Queue\Model\Entity\QueueProcess> paginate(\Cake\Datasource\RepositoryInterface|\Cake\Datasource\QueryInterface|string|null $object = null, array $settings = []) * @property \Queue\Model\Table\QueuedJobsTable $QueuedJobs */ -class QueueProcessesController extends AppController { - - use LoadHelperTrait; +class QueueProcessesController extends QueueAppController { /** * @var array @@ -26,15 +23,6 @@ class QueueProcessesController extends AppController { ], ]; - /** - * @return void - */ - public function initialize(): void { - parent::initialize(); - - $this->loadHelpers(); - } - /** * Index method * diff --git a/src/Controller/Admin/QueuedJobsController.php b/src/Controller/Admin/QueuedJobsController.php index ada5d711..73073ee1 100644 --- a/src/Controller/Admin/QueuedJobsController.php +++ b/src/Controller/Admin/QueuedJobsController.php @@ -3,7 +3,6 @@ namespace Queue\Controller\Admin; -use App\Controller\AppController; use Cake\Core\Configure; use Cake\Core\Plugin; use Cake\Http\Exception\NotFoundException; @@ -17,9 +16,7 @@ * @method \Cake\Datasource\ResultSetInterface<\Queue\Model\Entity\QueuedJob> paginate($object = null, array $settings = []) * @property \Search\Controller\Component\SearchComponent $Search */ -class QueuedJobsController extends AppController { - - use LoadHelperTrait; +class QueuedJobsController extends QueueAppController { /** * @var array @@ -37,7 +34,6 @@ public function initialize(): void { parent::initialize(); $this->enableSearch(); - $this->loadHelpers(); } /** @@ -81,19 +77,23 @@ public function index() { } /** - * Index method + * Stats method * - * @param string|null $jobType + * Uses query parameter `job_type` to filter by specific job type. + * Query parameter is used instead of route parameter to support job types + * containing slashes (e.g., Vendor/Plugin.Task). * * @throws \Cake\Http\Exception\NotFoundException * * @return void */ - public function stats(?string $jobType = null): void { + public function stats(): void { if (!Configure::read('Queue.isStatisticEnabled')) { throw new NotFoundException('Not enabled'); } + // Use query parameter to avoid routing issues with job types containing slashes (e.g., Vendor/Plugin.Task) + $jobType = $this->request->getQuery('job_type'); $stats = $this->QueuedJobs->getFullStats($jobType); $jobTypes = $this->QueuedJobs->find()->where()->find( @@ -101,7 +101,7 @@ public function stats(?string $jobType = null): void { keyField: 'job_task', valueField: 'job_task', )->distinct('job_task')->toArray(); - $this->set(compact('stats', 'jobTypes')); + $this->set(compact('stats', 'jobTypes', 'jobType')); } /** diff --git a/src/View/Helper/QueueHelper.php b/src/View/Helper/QueueHelper.php index 5e64c7aa..19d83c15 100644 --- a/src/View/Helper/QueueHelper.php +++ b/src/View/Helper/QueueHelper.php @@ -4,6 +4,7 @@ namespace Queue\View\Helper; use Cake\View\Helper; +use DateInterval; use Queue\Model\Entity\QueuedJob; use Queue\Queue\Config; use Queue\Queue\TaskFinder; @@ -62,6 +63,35 @@ public function attempts(QueuedJob $queuedJob): ?string { return $queuedJob->attempts . 'x'; } + /** + * Returns true if job has been requeued (has failure message but within retry limit). + * + * @param \Queue\Model\Entity\QueuedJob $queuedJob + * + * @return bool + */ + public function isRequeued(QueuedJob $queuedJob): bool { + if ($queuedJob->completed || !$queuedJob->fetched) { + return false; + } + + // Must have a failure message to be considered "requeued" + if (!$queuedJob->failure_message) { + return false; + } + + if ($queuedJob->attempts < 1) { + return false; + } + + $taskConfig = $this->taskConfig($queuedJob->job_task); + if ($taskConfig && $queuedJob->attempts <= $taskConfig['retries']) { + return true; + } + + return false; + } + /** * Returns failure status (message) if applicable. * @@ -132,4 +162,56 @@ public function secondsToHumanReadable(int $seconds): string { return $seconds . ' (' . implode(' ', $parts) . ')'; } + /** + * Returns the duration of a completed job. + * + * @param \Queue\Model\Entity\QueuedJob $queuedJob + * + * @return string|null Duration string or null if not calculable + */ + public function duration(QueuedJob $queuedJob): ?string { + if (!$queuedJob->completed) { + return null; + } + + if (!$queuedJob->fetched) { + return null; + } + + $interval = $queuedJob->completed->diff($queuedJob->fetched); + + return $this->formatInterval($interval); + } + + /** + * Formats a DateInterval into a human-readable duration string. + * + * @param \DateInterval $interval + * + * @return string + */ + public function formatInterval(DateInterval $interval): string { + $parts = []; + + if ($interval->d > 0) { + $parts[] = $interval->d . 'd'; + } + if ($interval->h > 0) { + $parts[] = $interval->h . 'h'; + } + if ($interval->i > 0) { + $parts[] = $interval->i . 'm'; + } + if ($interval->s > 0 || empty($parts)) { + $parts[] = $interval->s . 's'; + } + + // Minimum display is "< 1s" when no time parts + if (implode('', $parts) === '0s') { + return '< 1s'; + } + + return implode(' ', $parts); + } + } diff --git a/templates/Admin/Queue/index.php b/templates/Admin/Queue/index.php index 2269079f..d751595d 100644 --- a/templates/Admin/Queue/index.php +++ b/templates/Admin/Queue/index.php @@ -1,5 +1,4 @@ $configurations */ + use Cake\Core\Configure; ?> - - -
-

+ + + addMinutes(1)->isFuture(); + $relTime = method_exists($this->Time, 'relLengthOfTime') + ? $this->Time->relLengthOfTime($status['time']) + : $this->Time->timeAgoInWords($status['time']); + ?> +
+
+
+ + + + + + + +
+ +
+ + • + Html->link( + __d('queue', '{0} worker(s)', $workers), + ['action' => 'processes'], + ['class' => 'text-decoration-none'] + ) ?> + • + +
+
+
+
+ Html->link( + '' . __d('queue', 'Manage Workers'), + ['action' => 'processes'], + ['class' => 'btn btn-sm btn-outline-dark', 'escapeTitle' => false] + ) ?> +
+
+
+ +
+ + +
+ + + +
+
+ element('Queue.Queue/stats_card', [ + 'title' => __d('queue', 'Scheduled'), + 'count' => $scheduledJobs, + 'icon' => 'calendar', + 'color' => 'info', + ]) ?> +
+
+ element('Queue.Queue/stats_card', [ + 'title' => __d('queue', 'Pending'), + 'count' => $pendingJobs, + 'icon' => 'clock', + 'color' => 'warning', + ]) ?> +
+
+ element('Queue.Queue/stats_card', [ + 'title' => __d('queue', 'Running'), + 'count' => $runningJobs, + 'icon' => 'spinner', + 'color' => 'primary', + ]) ?> +
+
+ element('Queue.Queue/stats_card', [ + 'title' => __d('queue', 'Failed'), + 'count' => $failedJobs, + 'icon' => 'times-circle', + 'color' => 'danger', + ]) ?> +
+
-
- -

- - addMinutes(1)->isFuture(); - ?> - Time, 'relLengthOfTime') - ? $this->Time->relLengthOfTime($status['time']) - : $this->Time->timeAgoInWords($status['time']); - ?> - element('Queue.yes_no', ['value' => $running]); ?> () - - Currently ' . $this->Html->link($status['workers'] . ' worker(s)', ['action' => 'processes']) . ' total.
'; - ?> - ' . count($servers) . ' CLI server(s): ' . implode(', ', $servers) . '
'; - ?> - - - n/a - - -

-

- -

-
    - ' . $this->Html->link($pendingJob->job_task, ['controller' => 'QueuedJobs', 'action' => 'view', $pendingJob->id]) . ' (ref ' . h($pendingJob->reference ?: '-') . ', prio ' . $pendingJob->priority . '):'; - echo '
      '; - - $reset = ''; - if ($this->Queue->hasFailed($pendingJob)) { - $reset = ' ' . $this->Form->postLink(__d('queue', 'Soft reset'), ['action' => 'resetJob', $pendingJob->id], ['confirm' => 'Sure?', 'class' => 'button primary btn margin btn-primary']); - $reset .= ' ' . $this->Form->postLink(__d('queue', 'Remove'), ['action' => 'removeJob', $pendingJob->id], ['confirm' => 'Sure?', 'class' => 'button secondary btn margin btn-secondary']); - } elseif ($pendingJob->fetched) { - $reset .= ' ' . $this->Form->postLink(__d('queue', 'Remove'), ['action' => 'removeJob', $pendingJob->id], ['confirm' => 'Sure?', 'class' => 'button secondary btn margin btn-secondary']); - } - - $notBefore = ''; - if ($pendingJob->notbefore) { - $notBefore = ' (' . __d('queue', 'scheduled {0}', $this->Time->nice($pendingJob->notbefore)) . ')'; - } - - echo '
    • ' . __d('queue', 'Created') . ': ' . $this->Time->nice($pendingJob->created) . $notBefore . '
    • '; - - if ($pendingJob->fetched) { - echo '
    • ' . __d('queue', 'Fetched') . ': ' . $this->Time->nice($pendingJob->fetched) . '
    • '; - - $status = ''; - if ($pendingJob->status) { - $status = ' (' . __d('queue', 'status') . ': ' . h($pendingJob->status) . ')'; - } - - if (!$pendingJob->failure_message) { - echo '
    • '; - echo __d('queue', 'Progress') . ': '; - echo $this->QueueProgress->progress($pendingJob) . $status; - $textProgressBar = $this->QueueProgress->progressBar($pendingJob, 18); - echo '
      ' . $this->QueueProgress->htmlProgressBar($pendingJob, $textProgressBar); - echo '
    • '; - } else { - echo '
    • ' . $this->Queue->failureStatus($pendingJob) . ''; - echo '
      ' . __d('queue', 'Attempts') . ': ' . $this->Queue->attempts($pendingJob) . $reset . '
      '; - echo '
    • '; - if ($pendingJob->failure_message) { - echo '
    • ' . __d('queue', 'Failure Message') . ': ' . $this->Text->truncate($pendingJob->failure_message, 200) . '
    • '; - } - } - } - - echo '
    '; - echo ''; - } - ?> -
- -

- -

-
    - ' . $this->Html->link($pendingJob->job_task, ['controller' => 'QueuedJobs', 'action' => 'view', $pendingJob->id]) . ' (ref ' . h($pendingJob->reference ?: '-') . ', prio ' . $pendingJob->priority . '):'; - echo '
      '; - - $reset = ''; - if ($this->Queue->hasFailed($pendingJob)) { - $reset = ' ' . $this->Form->postLink(__d('queue', 'Soft reset'), ['action' => 'resetJob', $pendingJob->id], ['confirm' => 'Sure?', 'class' => 'button primary btn margin btn-primary']); - $reset .= ' ' . $this->Form->postLink(__d('queue', 'Remove'), ['action' => 'removeJob', $pendingJob->id], ['confirm' => 'Sure?', 'class' => 'button secondary btn margin btn-secondary']); - } elseif ($pendingJob->fetched) { - $reset .= ' ' . $this->Form->postLink(__d('queue', 'Remove'), ['action' => 'removeJob', $pendingJob->id], ['confirm' => 'Sure?', 'class' => 'button secondary btn margin btn-secondary']); - } - - $notBefore = ''; - if ($pendingJob->notbefore) { - $notBefore = ' (' . __d('queue', 'scheduled {0}', $this->Time->nice($pendingJob->notbefore)) . ')'; - } - - echo '
    • ' . __d('queue', 'Created') . ': ' . $this->Time->nice($pendingJob->created) . $notBefore . '
    • '; - - if ($pendingJob->fetched) { - echo '
    • ' . __d('queue', 'Fetched') . ': ' . $this->Time->nice($pendingJob->fetched) . '
    • '; - - $status = ''; - if ($pendingJob->status) { - $status = ' (' . __d('queue', 'status') . ': ' . h($pendingJob->status) . ')'; - } - - if (!$pendingJob->failure_message) { - echo '
    • '; - echo __d('queue', 'Progress') . ': '; - echo $this->QueueProgress->progress($pendingJob) . $status; - $textProgressBar = $this->QueueProgress->progressBar($pendingJob, 18); - echo '
      ' . $this->QueueProgress->htmlProgressBar($pendingJob, $textProgressBar); - echo '
    • '; - } else { - echo '
    • ' . $this->Queue->failureStatus($pendingJob) . ''; - echo '
      ' . __d('queue', 'Attempts') . ': ' . $this->Queue->attempts($pendingJob) . $reset . '
      '; - echo '
    • '; - if ($pendingJob->failure_message) { - echo '
    • ' . __d('queue', 'Failure Message') . ': ' . $this->Text->truncate($pendingJob->failure_message, 200) . '
    • '; - } - } - } - - echo '
    '; - echo ''; - } - ?> -
- -

-
    - ' . h($row['job_task']) . ':'; - echo '
      '; - echo '
    • Finished Jobs in Database: ' . $row['num'] . '
    • '; - echo '
    • Average Job existence: ' . $row['alltime'] . 's
    • '; - echo '
    • Average Execution delay: ' . $row['fetchdelay'] . 's
    • '; - echo '
    • Average Execution time: ' . $row['runtime'] . 's
    • '; - echo '
    '; - echo ''; - } - if (empty($data)) { - echo 'n/a'; - } - ?> -
- - -

Html->link(__d('queue', 'Detailed Statistics'), ['controller' => 'QueuedJobs', 'action' => 'stats']); ?>

- + +
+ +
+
+ (/) + Html->link( + __d('queue', 'View All'), + ['controller' => 'QueuedJobs', 'action' => 'index'], + ['class' => 'btn btn-sm btn-outline-primary'] + ) ?> +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ Html->link( + h($pendingJob->job_task), + ['controller' => 'QueuedJobs', 'action' => 'view', $pendingJob->id], + ['class' => 'text-decoration-none fw-medium'] + ) ?> + + reference ?: '-') ?> + + Time->nice($pendingJob->created) ?> + notbefore): ?> +
Time->nice($pendingJob->notbefore)) ?> + +
+ Queue->hasFailed($pendingJob)): ?> + + + +
: Queue->attempts($pendingJob) ?>
+ Queue->isRequeued($pendingJob)): ?> + + + +
: Queue->attempts($pendingJob) ?>
+ fetched): ?> + + + + failure_message): ?> +
+ QueueProgress->htmlProgressBar($pendingJob, $this->QueueProgress->progressBar($pendingJob, 18)) ?> +
+ + + + + + +
+ Queue->hasFailed($pendingJob)): ?> + Form->postLink( + '', + ['action' => 'resetJob', $pendingJob->id], + [ + 'escapeTitle' => false, + 'class' => 'btn btn-sm btn-outline-primary', + 'confirm' => __d('queue', 'Sure?'), + 'title' => __d('queue', 'Reset'), + 'block' => true, + ] + ) ?> + Form->postLink( + '', + ['action' => 'removeJob', $pendingJob->id], + [ + 'escapeTitle' => false, + 'class' => 'btn btn-sm btn-outline-danger', + 'confirm' => __d('queue', 'Sure?'), + 'title' => __d('queue', 'Remove'), + 'block' => true, + ] + ) ?> + fetched): ?> + Form->postLink( + '', + ['action' => 'removeJob', $pendingJob->id], + [ + 'escapeTitle' => false, + 'class' => 'btn btn-sm btn-outline-danger', + 'confirm' => __d('queue', 'Sure?'), + 'title' => __d('queue', 'Remove'), + 'block' => true, + ] + ) ?> + +
+
+ +
+ +

+
+ +
+
+ + + +
+
+ () +
+
+
+ + + + + + + + + + + + + + + + + + + +
+ Html->link( + h($scheduledJob->job_task), + ['controller' => 'QueuedJobs', 'action' => 'view', $scheduledJob->id] + ) ?> + reference ?: '-') ?> + Time->nice($scheduledJob->notbefore) ?> + notbefore): ?> +
+ Time, 'relLengthOfTime') + ? $this->Time->relLengthOfTime($scheduledJob->notbefore) + : $this->Time->timeAgoInWords($scheduledJob->notbefore) ?> +
+ +
+ Form->postLink( + '', + ['action' => 'removeJob', $scheduledJob->id], + [ + 'escapeTitle' => false, + 'class' => 'btn btn-sm btn-outline-danger', + 'confirm' => __d('queue', 'Sure?'), + 'title' => __d('queue', 'Remove'), + 'block' => true, + ] + ) ?> +
+
+
+
+ + + + +
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + +
sss
+
+
+ + + +
+
-
- -

Settings

- Server: -
    -
  • - posix extension enabled (optional, recommended): element('Queue.yes_no', ['value' => function_exists('posix_kill')]); ?> -
  • -
- - Current runtime configuration: -
    - No configuration found'; - } - - $timeConfigKeys = [ - 'workerLifetime', - 'workerPhpTimeout', - 'defaultRequeueTimeout', - 'cleanuptimeout', - 'sleeptime', - 'workermaxruntime', - 'workertimeout', - 'defaultworkertimeout', - ]; - foreach ($configurations as $key => $configuration) { - echo '
  • '; - if (is_string($configuration) && is_dir($configuration)) { - $configuration = str_replace(ROOT . DS, 'ROOT' . DS, $configuration); - $configuration = str_replace(DS, '/', $configuration); - } elseif (is_bool($configuration)) { - $configuration = $configuration ? 'true' : 'false'; - } elseif (is_array($configuration)) { - $configuration = implode(', ', $configuration); - } elseif (is_int($configuration) && in_array($key, $timeConfigKeys, true)) { - $configuration = $this->Queue->secondsToHumanReadable($configuration); - } - echo h($key) . ': ' . h($configuration); - echo '
  • '; - } - - ?> -
- -

Trigger Jobs

-

These jobs implement the AddFromBackendInterface

-
    - $className) { - if (substr($task, 0, 6) === 'Queue.' && (substr($task, -7) === 'Example' || $task === 'Queue.Execute')) { - continue; - } - - echo '
  • '; - echo $this->Form->postLink($task, ['action' => 'addJob', '?' => ['task' => $task]], ['confirm' => 'Sure?']); - echo '
  • '; - } - ?> -
-

Jobs just implementing AddInterface can be added from CLI instead.

- -

Trigger Test/Demo Jobs

-
    - $className) { - if (substr($task, -7) !== 'Example') { - continue; - } - - echo '
  • '; - echo $this->Form->postLink($task, ['action' => 'addJob', '?' => ['task' => $task]], ['confirm' => 'Sure?']); - echo '
  • '; - } - ?> -
- -

Html->link(__d('queue', 'Trigger Delayed Test/Demo Job'), ['controller' => 'QueuedJobs', 'action' => 'test']); ?>

- -

Html->link(__d('queue', 'Trigger Execute Job(s)'), ['controller' => 'QueuedJobs', 'action' => 'execute']); ?>

- + +
+ +
+
+ +
+
+

+ +
+ $className): ?> + + Form->postLink( + '' . h($task), + ['action' => 'addJob', '?' => ['task' => $task]], + [ + 'escapeTitle' => false, + 'class' => 'btn btn-outline-primary btn-sm text-start', + 'confirm' => __d('queue', 'Sure?'), + 'block' => true, + ] + ) ?> + +
+ +

+ + +
+ Html->link( + '' . __d('queue', 'Execute Job'), + ['controller' => 'QueuedJobs', 'action' => 'execute'], + ['class' => 'btn btn-outline-warning btn-sm w-100', 'escapeTitle' => false] + ) ?> + + +
+ +
+
+ $className): ?> + + Form->postLink( + '' . h($task), + ['action' => 'addJob', '?' => ['task' => $task]], + [ + 'escapeTitle' => false, + 'class' => 'btn btn-outline-secondary btn-sm text-start', + 'confirm' => __d('queue', 'Sure?'), + 'block' => true, + ] + ) ?> + + Html->link( + '' . __d('queue', 'Trigger Delayed Test Job'), + ['controller' => 'QueuedJobs', 'action' => 'test'], + ['class' => 'btn btn-outline-info btn-sm', 'escapeTitle' => false] + ) ?> +
+
+
+
+ + +
+
+ +
+
+
+ +
    +
  • + posix + element('Queue.yes_no', ['value' => function_exists('posix_kill')]) ?> +
  • +
+
+
+ + + +
    + $configuration): ?> +
  • + + + Queue->secondsToHumanReadable($configuration); + } + echo '' . h($configuration) . ''; + ?> + +
  • + +
+ +

+ +
+
+
- -
diff --git a/templates/Admin/Queue/processes.php b/templates/Admin/Queue/processes.php index 7b6378ab..ba35f196 100644 --- a/templates/Admin/Queue/processes.php +++ b/templates/Admin/Queue/processes.php @@ -11,57 +11,119 @@ ?> +

+ + +

- + +
+
+ + +
+
+ +
+ +
+
+
+
+ + PID: pid) ?> +
+ +
-
-

+
+ : + active_job): ?> + Html->link( + h($process->active_job->job_task), + ['controller' => 'QueuedJobs', 'action' => 'view', $process->active_job->id], + ['class' => 'text-decoration-none'] + ) ?> + + + +
-

-

:

+
+ + : Time->nice(new DateTime($process->modified)) ?> +
-
    -' . $process->pid . ':'; - echo '
      '; - echo '
    • Current active job: ' . ($process->active_job ? $this->Html->link($process->active_job->job_task, [ - 'controller' => 'QueuedJobs', - 'action' => 'view', - $process->active_job->id - ]) : 'Currently no job is being processed by this worker') . '
    • '; - echo '
    • Last run: ' . $this->Time->nice(new DateTime($process->modified)) . '
    • '; - - echo '
    • End: ' . $this->Form->postLink(__d('queue', 'Finish current job and end'), ['action' => 'processes', '?' => ['end' => $process->pid]], ['confirm' => 'Sure?', 'class' => 'button secondary btn margin btn-secondary']) . ' (next loop run)
    • '; - if ($process->workerkey === $key || !$this->Configure->read('Queue.multiserver')) { - echo '
    • ' . __d('queue', 'Kill') . ': ' . $this->Form->postLink(__d('queue', 'Soft kill'), ['action' => 'processes', '?' => ['kill' => $process->pid]], ['confirm' => 'Sure?']) . ' (termination SIGTERM = 15)
    • '; - } +
      + Form->postLink( + '' . __d('queue', 'Finish & End'), + ['action' => 'processes', '?' => ['end' => $process->pid]], + [ + 'escapeTitle' => false, + 'class' => 'btn btn-sm btn-outline-warning', + 'confirm' => __d('queue', 'Sure?'), + 'title' => __d('queue', 'Finish current job and end worker'), + 'block' => true, + ] + ) ?> - echo '
    '; - echo ''; -} -if (empty($processes)) { - echo 'n/a'; -} -?> -
+ workerkey === $key || !$this->Configure->read('Queue.multiserver')): ?> + Form->postLink( + '' . __d('queue', 'Kill'), + ['action' => 'processes', '?' => ['kill' => $process->pid]], + [ + 'escapeTitle' => false, + 'class' => 'btn btn-sm btn-outline-danger', + 'confirm' => __d('queue', 'Sure? This sends SIGTERM to the process.'), + 'title' => __d('queue', 'Send SIGTERM to terminate immediately'), + 'block' => true, + ] + ) ?> + +
+
+
+ +
+ +
+ +

+

+
+ +
+
- -

-

:

- - + + +
+
+ + +
+
+

:

+
    + +
  • + + + PID: pid) ?> + + +
  • + +
+
+
+ + +
+ Html->link( + '' . __d('queue', 'View Process History'), + ['controller' => 'QueueProcesses', 'action' => 'index'], + ['class' => 'btn btn-outline-secondary', 'escapeTitle' => false] + ) ?>
diff --git a/templates/Admin/QueueProcesses/edit.php b/templates/Admin/QueueProcesses/edit.php index 296ba9ab..7cf1c983 100644 --- a/templates/Admin/QueueProcesses/edit.php +++ b/templates/Admin/QueueProcesses/edit.php @@ -4,24 +4,28 @@ * @var \Queue\Model\Entity\QueueProcess $queueProcess */ ?> - -
- -

pid); ?>

- - Form->create($queueProcess) ?> -
- - Form->control('server'); - ?> -
- Form->button(__d('queue', 'Submit')) ?> - Form->end() ?> +
+
+
+
+ - PID pid) ?> + Html->link( + '' . __d('queue', 'Back'), + ['action' => 'view', $queueProcess->id], + ['class' => 'btn btn-sm btn-outline-secondary', 'escapeTitle' => false] + ) ?> +
+
+ Form->create($queueProcess) ?> + Form->control('server', [ + 'class' => 'form-control', + 'label' => __d('queue', 'Server Identifier'), + ]) ?> +
+ Form->button('' . __d('queue', 'Save'), ['class' => 'btn btn-primary', 'escapeTitle' => false]) ?> +
+ Form->end() ?> +
+
+
diff --git a/templates/Admin/QueueProcesses/index.php b/templates/Admin/QueueProcesses/index.php index 20889b77..d27ba261 100644 --- a/templates/Admin/QueueProcesses/index.php +++ b/templates/Admin/QueueProcesses/index.php @@ -3,66 +3,126 @@ * @var \App\View\AppView $this * @var iterable<\Queue\Model\Entity\QueueProcess> $queueProcesses */ + use Queue\Queue\Config; + ?> - -
-

- - - - - - - - - - - - - - - - - - - - - - - -
Paginator->sort('pid') ?>Paginator->sort('created', __d('queue', 'Started'), ['direction' => 'desc']) ?>Paginator->sort('modified', __d('queue', 'Last Run'), ['direction' => 'desc']) ?>Paginator->sort('terminate', __d('queue', 'Active')) ?>Paginator->sort('server') ?>
- pid) ?> - workerkey && $queueProcess->workerkey !== $queueProcess->pid) { ?> -
workerkey); ?>
- -
- Time->nice($queueProcess->created) ?> - created->addSeconds(Config::workermaxruntime())->isFuture()) { - echo $this->element('Queue.icon', ['name' => 'exclamation-triangle', 'attributes' => ['title' => 'Long running (!)']]); - } ?> - - Time->nice($queueProcess->modified); - if (!$queueProcess->created->addSeconds(Config::defaultworkertimeout())->isFuture()) { - $modified = '' . $modified . ''; - } - echo $modified; - ?> - element('Queue.yes_no', ['value' => !$queueProcess->terminate]) ?>server) ?> - Html->link($this->element('Queue.icon', ['name' => 'view']), ['action' => 'view', $queueProcess->id], ['escapeTitle' => false]); ?> - terminate) { ?> - Form->postLink($this->element('Queue.icon', ['name' => 'times', 'attributes' => ['title' => __d('queue', 'Terminate')]]), ['action' => 'terminate', $queueProcess->id], ['escapeTitle' => false, 'confirm' => __d('queue', 'Are you sure you want to terminate # {0}?', $queueProcess->id)]); ?> - -
+
+

+ + +

+
+ Html->link( + '' . __d('queue', 'Active Workers'), + ['controller' => 'Queue', 'action' => 'processes'], + ['class' => 'btn btn-outline-primary btn-sm', 'escapeTitle' => false] + ) ?> + Form->postLink( + '' . __d('queue', 'Cleanup'), + ['action' => 'cleanup'], + [ + 'class' => 'btn btn-outline-warning btn-sm', + 'escapeTitle' => false, + 'confirm' => __d('queue', 'Sure to remove all outdated ones (>{0}s)?', Config::defaultworkertimeout() * 2), + 'block' => true, + ] + ) ?> +
+
- element('Tools.pagination'); ?> +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
Paginator->sort('pid', __d('queue', 'PID')) ?>Paginator->sort('created', __d('queue', 'Started'), ['direction' => 'desc']) ?>Paginator->sort('modified', __d('queue', 'Last Run'), ['direction' => 'desc']) ?>Paginator->sort('terminate', __d('queue', 'Active')) ?>Paginator->sort('server', __d('queue', 'Server')) ?>
+ pid) ?> + workerkey && $queueProcess->workerkey !== $queueProcess->pid): ?> +
+ workerkey) ?> +
+ +
+ Time->nice($queueProcess->created) ?> + created->addSeconds(Config::workermaxruntime())->isFuture()): ?> + + + + + + Time->nice($queueProcess->modified); + $isStale = !$queueProcess->modified->addSeconds(Config::defaultworkertimeout())->isFuture(); + ?> + + + + + + + + + element('Queue.yes_no', ['value' => !$queueProcess->terminate]) ?> + + server): ?> + server) ?> + + --- + + +
+ Html->link( + '', + ['action' => 'view', $queueProcess->id], + [ + 'escapeTitle' => false, + 'class' => 'btn btn-outline-primary', + 'title' => __d('queue', 'View'), + 'aria-label' => __d('queue', 'View'), + ] + ) ?> + terminate): ?> + Form->postLink( + '', + ['action' => 'terminate', $queueProcess->id], + [ + 'escapeTitle' => false, + 'class' => 'btn btn-outline-warning', + 'confirm' => __d('queue', 'Are you sure you want to terminate # {0}?', $queueProcess->id), + 'title' => __d('queue', 'Terminate'), + 'aria-label' => __d('queue', 'Terminate'), + 'block' => true, + ] + ) ?> + +
+
+
+
+
diff --git a/templates/Admin/QueueProcesses/view.php b/templates/Admin/QueueProcesses/view.php index 9c20af7e..480da6ef 100644 --- a/templates/Admin/QueueProcesses/view.php +++ b/templates/Admin/QueueProcesses/view.php @@ -5,51 +5,104 @@ */ use Queue\Queue\Config; ?> - -
-

PID pid) ?>

- - - - - - - - - - - - - - - - - - - - - -
- Time->nice($queueProcess->created) ?> - created->addSeconds(Config::defaultworkertimeout())->isFuture()) { - echo $this->element('Queue.icon', ['name' => 'exclamation-triangle', 'attributes' => ['title' => 'Long running (!)']]); - } ?> -
Time->nice($queueProcess->modified) ?>
- element('Queue.yes_no', ['value' => !$queueProcess->terminate]) ?> - terminate ? 'Yes' : 'No' ?> -
server) ?>
workerkey) ?>
+
+
+
+
+ + PID pid) ?> +
+
+ + + + + + + + + + + + + + + + + + + + + +
+ Time->nice($queueProcess->created) ?> + created->addSeconds(Config::defaultworkertimeout())->isFuture()): ?> + + + + +
Time->nice($queueProcess->modified) ?>
+ terminate): ?> + + + + + + + + +
+ server): ?> + server) ?> + + --- + +
+ workerkey): ?> + workerkey) ?> + + --- + +
+
+
+
+
+
+
+ +
+
+ Html->link( + '' . __d('queue', 'Edit Process'), + ['action' => 'edit', $queueProcess->id], + ['class' => 'list-group-item list-group-item-action', 'escapeTitle' => false] + ) ?> + terminate): ?> + Form->postLink( + '' . __d('queue', 'Terminate (Graceful)'), + ['action' => 'terminate', $queueProcess->id], + [ + 'class' => 'list-group-item list-group-item-action text-warning', + 'escapeTitle' => false, + 'confirm' => __d('queue', 'Are you sure you want to terminate # {0}?', $queueProcess->id), + 'block' => true, + ] + ) ?> + + Form->postLink( + '' . __d('queue', 'Delete (Force)'), + ['action' => 'delete', $queueProcess->id], + [ + 'class' => 'list-group-item list-group-item-action text-danger', + 'escapeTitle' => false, + 'confirm' => __d('queue', 'Are you sure you want to delete # {0}?', $queueProcess->id), + 'block' => true, + ] + ) ?> + +
+
+
diff --git a/templates/Admin/QueuedJobs/data.php b/templates/Admin/QueuedJobs/data.php index aff0d2ee..ebe592d2 100644 --- a/templates/Admin/QueuedJobs/data.php +++ b/templates/Admin/QueuedJobs/data.php @@ -4,23 +4,29 @@ * @var \Queue\Model\Entity\QueuedJob $queuedJob */ ?> - -
-

- - Form->create($queuedJob) ?> -
- - Form->control('data_string', ['rows' => 20]); - ?> -
- Form->button(__d('queue', 'Submit')) ?> - Form->end() ?> +
+
+
+
+ + Html->link( + '' . __d('queue', 'Back'), + ['action' => 'edit', $queuedJob->id], + ['class' => 'btn btn-sm btn-outline-secondary', 'escapeTitle' => false] + ) ?> +
+
+ Form->create($queuedJob) ?> + Form->control('data_string', [ + 'rows' => 20, + 'class' => 'form-control font-monospace', + 'label' => __d('queue', 'Payload Data (JSON/Serialized)'), + ]) ?> +
+ Form->button('' . __d('queue', 'Save'), ['class' => 'btn btn-primary', 'escapeTitle' => false]) ?> +
+ Form->end() ?> +
+
+
diff --git a/templates/Admin/QueuedJobs/edit.php b/templates/Admin/QueuedJobs/edit.php index ae81f1fe..acfef260 100644 --- a/templates/Admin/QueuedJobs/edit.php +++ b/templates/Admin/QueuedJobs/edit.php @@ -4,31 +4,51 @@ * @var \Queue\Model\Entity\QueuedJob $queuedJob */ ?> - -
-

+
+
+
+
+ + Html->link( + '' . __d('queue', 'Back'), + ['action' => 'view', $queuedJob->id], + ['class' => 'btn btn-sm btn-outline-secondary', 'escapeTitle' => false] + ) ?> +
+
+ Form->create($queuedJob) ?> + Form->control('notbefore', ['empty' => true, 'class' => 'form-control']) ?> + Form->control('priority', ['class' => 'form-control']) ?> +
+ Form->button('' . __d('queue', 'Save'), ['class' => 'btn btn-primary', 'escapeTitle' => false]) ?> +
+ Form->end() ?> +
+
+
- Form->create($queuedJob) ?> -
- - Form->control('notbefore', ['empty' => true]); - echo $this->Form->control('priority'); - ?> -
- Form->button(__d('queue', 'Submit')) ?> - Form->end() ?> +
+
+
+ +
+
+ Html->link( + '' . __d('queue', 'Edit Payload'), + ['action' => 'data', $queuedJob->id], + ['class' => 'list-group-item list-group-item-action', 'escapeTitle' => false] + ) ?> + Form->postLink( + '' . __d('queue', 'Delete Job'), + ['action' => 'delete', $queuedJob->id], + [ + 'class' => 'list-group-item list-group-item-action text-danger', + 'escapeTitle' => false, + 'confirm' => __d('queue', 'Are you sure you want to delete # {0}?', $queuedJob->id), + 'block' => true, + ] + ) ?> +
+
+
diff --git a/templates/Admin/QueuedJobs/execute.php b/templates/Admin/QueuedJobs/execute.php index 9fc6aa9a..9ffc0277 100644 --- a/templates/Admin/QueuedJobs/execute.php +++ b/templates/Admin/QueuedJobs/execute.php @@ -3,30 +3,67 @@ * @var \App\View\AppView $this */ ?> - -
-

+
+
+
+
+ +
+
+ Form->create(null) ?> + Form->control('command', [ + 'placeholder' => 'bin/cake foo bar --baz', + 'class' => 'form-control font-monospace', + 'label' => __d('queue', 'Command'), + ]) ?> - Form->create(null) ?> -
- - Form->control('command', ['placeholder' => 'bin/cake foo bar --baz']); - echo $this->Form->control('escape', ['type' => 'checkbox', 'default' => true]); - echo $this->Form->control('log', ['type' => 'checkbox', 'default' => true]); - echo $this->Form->control('exit_code', ['placeholder' => 'Defaults to 0 (success)', 'default' => '0']); +
+
+ Form->control('escape', [ + 'type' => 'checkbox', + 'default' => true, + 'class' => 'form-check-input', + 'label' => ['text' => __d('queue', 'Escape command'), 'class' => 'form-check-label'], + ]) ?> +
+
+ Form->control('log', [ + 'type' => 'checkbox', + 'default' => true, + 'class' => 'form-check-input', + 'label' => ['text' => __d('queue', 'Enable logging'), 'class' => 'form-check-label'], + ]) ?> +
+
- echo '

Escaping is recommended to keep on.

'; +
+
+ Form->control('exit_code', [ + 'placeholder' => '0', + 'default' => '0', + 'class' => 'form-control', + 'label' => __d('queue', 'Expected Exit Code'), + ]) ?> +
+
+ Form->control('amount', [ + 'default' => 1, + 'class' => 'form-control', + 'label' => __d('queue', 'Amount of jobs to spawn'), + ]) ?> +
+
- echo $this->Form->control('amount', ['default' => 1, 'label' => 'Amount of jobs to spawn']); - ?> -
- Form->button(__d('queue', 'Submit')) ?> - Form->end() ?> +
+ + +
+ +
+ Form->button('' . __d('queue', 'Create Job'), ['class' => 'btn btn-warning', 'escapeTitle' => false]) ?> +
+ Form->end() ?> +
+
+
diff --git a/templates/Admin/QueuedJobs/import.php b/templates/Admin/QueuedJobs/import.php index 60c2d417..1c72af02 100644 --- a/templates/Admin/QueuedJobs/import.php +++ b/templates/Admin/QueuedJobs/import.php @@ -3,23 +3,41 @@ * @var \App\View\AppView $this */ ?> - -
-

Import

+
+
+
+
+ +
+
+ Form->create(null, ['type' => 'file']) ?> + Form->control('file', [ + 'type' => 'file', + 'required' => true, + 'accept' => '.json', + 'class' => 'form-control', + 'label' => __d('queue', 'JSON File'), + ]) ?> - Form->create(null, ['type' => 'file']) ?> -
- - Form->control('file', ['type' => 'file', 'required' => true, 'accept' => '.json']); - echo $this->Form->control('reset', ['type' => 'checkbox', 'default' => true]); - ?> -
- Form->button(__d('queue', 'Submit')) ?> - Form->end() ?> +
+ Form->control('reset', [ + 'type' => 'checkbox', + 'default' => true, + 'class' => 'form-check-input', + 'label' => ['text' => __d('queue', 'Reset job state (clear completed/failed status)'), 'class' => 'form-check-label'], + ]) ?> +
+ +

+ + +

+ +
+ Form->button('' . __d('queue', 'Import'), ['class' => 'btn btn-primary', 'escapeTitle' => false]) ?> +
+ Form->end() ?> +
+
+
diff --git a/templates/Admin/QueuedJobs/index.php b/templates/Admin/QueuedJobs/index.php index 0c5675be..b76437f8 100644 --- a/templates/Admin/QueuedJobs/index.php +++ b/templates/Admin/QueuedJobs/index.php @@ -9,148 +9,230 @@ use Cake\Core\Plugin; ?> - -
- - element('Queue.search'); - } - ?> - -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + +
Paginator->sort('job_task') ?>Paginator->sort('job_group') ?>Paginator->sort('reference') ?>Paginator->sort('created', null, ['direction' => 'desc']) ?>Paginator->sort('notbefore', null, ['direction' => 'desc']) ?>Paginator->sort('fetched', null, ['direction' => 'desc']) ?>Paginator->sort('completed', null, ['direction' => 'desc']) ?>Paginator->sort('attempts') ?>Paginator->sort('status') ?>Paginator->sort('priority', null, ['direction' => 'desc']) ?>
job_task) ?>job_group) ?: '---' ?> - reference) ?: '---' ?> - data) { - $data = $queuedJob->data; - if ($data && !is_array($data)) { - $data = json_decode($queuedJob->data, true); - } - $data = VarExporter::export($data, VarExporter::TRAILING_COMMA_IN_ARRAY); - echo $this->element('Queue.icon', ['name' => 'cubes', 'attributes' => ['title' => $this->Text->truncate($data, 1000)]]); - } - ?> - Time->nice($queuedJob->created) ?> - Time->nice($queuedJob->notbefore) ?> -
- QueueProgress->timeoutProgressBar($queuedJob, 8); ?> - notbefore && $queuedJob->notbefore->isFuture()) { - echo '
'; - echo method_exists($this->Time, 'relLengthOfTime') - ? $this->Time->relLengthOfTime($queuedJob->notbefore) - : $this->Time->timeAgoInWords($queuedJob->notbefore); - echo '
'; - } ?> -
- Time->nice($queuedJob->fetched) ?> +
+

+ + +

+
+ Configure->read('debug')): ?> + Html->link( + '' . __d('queue', 'Import'), + ['action' => 'import'], + ['class' => 'btn btn-outline-secondary btn-sm', 'escapeTitle' => false] + ) ?> + +
+
- fetched) { - echo '
'; - echo method_exists($this->Time, 'relLengthOfTime') - ? $this->Time->relLengthOfTime($queuedJob->fetched) - : $this->Time->timeAgoInWords($queuedJob->fetched); - echo '
'; - } ?> +element('Queue.search'); +} +?> - workerkey) { ?> -
workerkey); ?>
- -
- element('Queue.ok', ['value' => $this->Time->nice($queuedJob->completed), 'ok' => (bool)$queuedJob->completed]) ?> - completed) { ?> -
- completed->diff($queuedJob->fetched); - $duration = method_exists($this->Time, 'duration') - ? $this->Time->duration($interval) - : ltrim($interval->format('%H:%I:%S'), '0:'); - echo '' . $duration . ''; - ?> -
- -
- element('Queue.ok', ['value' => $this->Queue->attempts($queuedJob), 'ok' => $queuedJob->completed || $queuedJob->attempts < 1]); ?> - - status) ?> - completed && $queuedJob->fetched) { ?> -
- failure_message) { ?> - QueueProgress->progress($queuedJob) ?> -
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + - - + + + + + + - - - -
Paginator->sort('job_task', __d('queue', 'Task')) ?>Paginator->sort('job_group', __d('queue', 'Group')) ?>Paginator->sort('reference', __d('queue', 'Reference')) ?>Paginator->sort('created', __d('queue', 'Created'), ['direction' => 'desc']) ?>Paginator->sort('notbefore', __d('queue', 'Scheduled'), ['direction' => 'desc']) ?>Paginator->sort('fetched', __d('queue', 'Fetched'), ['direction' => 'desc']) ?>Paginator->sort('completed', __d('queue', 'Completed'), ['direction' => 'desc']) ?>Paginator->sort('attempts', __d('queue', 'Attempts')) ?>Paginator->sort('status', __d('queue', 'Status')) ?>Paginator->sort('priority', __d('queue', 'Priority'), ['direction' => 'desc']) ?>
+ job_task) ?> + + job_group): ?> + job_group) ?> + + --- + + + reference): ?> + reference) ?> + + --- + + data): ?> QueueProgress->progressBar($queuedJob, 8); - echo $this->QueueProgress->htmlProgressBar($queuedJob, $textProgressBar); + $data = $queuedJob->data; + if ($data && !is_array($data)) { + $data = json_decode($queuedJob->data, true); + } + $dataStr = VarExporter::export($data, VarExporter::TRAILING_COMMA_IN_ARRAY); ?> - - Queue->failureStatus($queuedJob); ?> - - - - - completed) { ?> - - - - memory) { ?> -
Number->format($queuedJob->memory); ?> MB
- -
Number->format($queuedJob->priority) ?> - Html->link($this->element('Queue.icon', ['name' => 'view']), ['action' => 'view', $queuedJob->id], ['escapeTitle' => false]); ?> + + + + + + Time->nice($queuedJob->created) ?> + + notbefore): ?> + Time->nice($queuedJob->notbefore) ?> + QueueProgress->timeoutProgressBar($queuedJob, 8) ?> + notbefore->isFuture()): ?> +
+ Time, 'relLengthOfTime') + ? $this->Time->relLengthOfTime($queuedJob->notbefore) + : $this->Time->timeAgoInWords($queuedJob->notbefore) ?> +
+ + + --- + +
+ fetched): ?> + Time->nice($queuedJob->fetched) ?> +
+ Time, 'relLengthOfTime') + ? $this->Time->relLengthOfTime($queuedJob->fetched) + : $this->Time->timeAgoInWords($queuedJob->fetched) ?> +
+ workerkey): ?> +
workerkey) ?>
+ + + --- + +
+ completed): ?> + + + Time->nice($queuedJob->completed) ?> + + Queue->duration($queuedJob); ?> + +
+ + +
+ + + --- + +
+ element('Queue.ok', [ + 'value' => $this->Queue->attempts($queuedJob), + 'ok' => !$this->Queue->hasFailed($queuedJob), + 'warning' => $this->Queue->isRequeued($queuedJob), + ]) ?> + + completed): ?> + + + + Queue->hasFailed($queuedJob)): ?> + + + + Queue->isRequeued($queuedJob)): ?> + + + + fetched): ?> + + + + failure_message): ?> +
+ QueueProgress->progress($queuedJob) ?> +
+ QueueProgress->htmlProgressBar($queuedJob, $this->QueueProgress->progressBar($queuedJob, 8)) ?> +
+ + notbefore && $queuedJob->notbefore->isFuture()): ?> + + + + + + + + - completed) { ?> - Html->link($this->element('Queue.icon', ['name' => 'edit']), ['action' => 'edit', $queuedJob->id], ['escapeTitle' => false]); ?> - - Form->postLink($this->element('Queue.icon', ['name' => 'delete']), ['action' => 'delete', $queuedJob->id], ['escapeTitle' => false, 'confirm' => __d('queue', 'Are you sure you want to delete # {0}?', $queuedJob->id)]); ?> -
+ status): ?> +
status) ?>
+ - element('Tools.pagination'); ?> + memory): ?> +
+ + Number->format($queuedJob->memory) ?> MB +
+ +
+ Number->format($queuedJob->priority) ?> + +
+ Html->link( + '', + ['action' => 'view', $queuedJob->id], + [ + 'escapeTitle' => false, + 'class' => 'btn btn-outline-primary', + 'title' => __d('queue', 'View'), + 'aria-label' => __d('queue', 'View'), + ] + ) ?> + completed): ?> + Html->link( + '', + ['action' => 'edit', $queuedJob->id], + [ + 'escapeTitle' => false, + 'class' => 'btn btn-outline-secondary', + 'title' => __d('queue', 'Edit'), + 'aria-label' => __d('queue', 'Edit'), + ] + ) ?> + + Form->postLink( + '', + ['action' => 'delete', $queuedJob->id], + [ + 'escapeTitle' => false, + 'class' => 'btn btn-outline-danger', + 'confirm' => __d('queue', 'Are you sure you want to delete # {0}?', $queuedJob->id), + 'title' => __d('queue', 'Delete'), + 'aria-label' => __d('queue', 'Delete'), + 'block' => true, + ] + ) ?> +
+
+
+
+
diff --git a/templates/Admin/QueuedJobs/migrate.php b/templates/Admin/QueuedJobs/migrate.php index e690427d..bca269be 100644 --- a/templates/Admin/QueuedJobs/migrate.php +++ b/templates/Admin/QueuedJobs/migrate.php @@ -4,24 +4,47 @@ * @var string[] $tasks */ ?> - -
-

+
+
+
+
+ +
+
+
+ + +
- Form->create() ?> -
- - $fullName) { - echo $this->Form->control('tasks.' . $name, ['type' => 'checkbox', 'label' => $name . ' => ' . $fullName, 'default' => true]); - } - ?> -
- Form->button(__d('queue', 'Submit')) ?> - Form->end() ?> + Form->create() ?> +
+ + + + + + + + + + $fullName): ?> + + + + + + + +
+ Form->checkbox('tasks.' . $name, ['default' => true, 'class' => 'form-check-input']) ?> +
+
+ +
+ Form->button('' . __d('queue', 'Migrate Selected'), ['class' => 'btn btn-primary', 'escapeTitle' => false]) ?> +
+ Form->end() ?> +
+
+
diff --git a/templates/Admin/QueuedJobs/stats.php b/templates/Admin/QueuedJobs/stats.php index a0747310..66ca4bac 100644 --- a/templates/Admin/QueuedJobs/stats.php +++ b/templates/Admin/QueuedJobs/stats.php @@ -1,44 +1,58 @@ > $stats * @var string[] $jobTypes + * @var string|null $jobType */ ?> - - - -
-

-
-
- -

- -

For already processed jobs - in average seconds per timeframe.

- - - - -

Select a specific job type

-
    - -
  • Html->link($jobType, ['action' => 'stats', $jobType]); ?>
  • - -
+
+
+
+ + + + + + + + Html->link( + '' . __d('queue', 'All Jobs'), + ['action' => 'stats', '?' => []], + ['class' => 'btn btn-sm btn-outline-secondary', 'escapeTitle' => false] + ) ?> + +
+
+

+ +
+ +
+
+
-
+
+
+
+ +
+
+ + Html->link( + '' . h($type), + ['action' => 'stats', '?' => ['job_type' => $type]], + ['class' => 'list-group-item list-group-item-action', 'escapeTitle' => false] + ) ?> + +
+
+
$days) { - $data = implode(', ', $days); - - $dataSets[] = << $type, + 'data' => array_values($days), + 'borderColor' => $colors[$colorIndex % count($colors)], + 'backgroundColor' => $colors[$colorIndex % count($colors)], + 'fill' => false, + 'tension' => 0.1, + ]; + $colorIndex++; } - ?> -append('script');?> - +append('script'); ?> -end();?> +end(); ?> diff --git a/templates/Admin/QueuedJobs/test.php b/templates/Admin/QueuedJobs/test.php index a53096d8..f02ca30d 100644 --- a/templates/Admin/QueuedJobs/test.php +++ b/templates/Admin/QueuedJobs/test.php @@ -5,28 +5,41 @@ * @var string[] $tasks */ ?> - -
-

+
+
+
+
+ +
+
+ Form->create($queuedJob) ?> + Form->control('job_task', [ + 'options' => $tasks, + 'empty' => __d('queue', '-- Select Task --'), + 'class' => 'form-select', + ]) ?> - Form->create($queuedJob) ?> -
- - Form->control('job_task', ['options' => $tasks, 'empty' => true]); +
+ + : +
- echo '

Current (server) time: ' . (new \Cake\I18n\DateTime()) . ''; + Form->control('notbefore', [ + 'default' => (new \Cake\I18n\DateTime())->addMinutes(5), + 'class' => 'form-control', + 'label' => __d('queue', 'Schedule For (Not Before)'), + ]) ?> - echo $this->Form->control('notbefore', ['default' => (new \Cake\I18n\DateTime())->addMinutes(5)]); +

+ + +

- echo '

The target time must also be in that (server) time(zone).

'; - ?> -
- Form->button(__d('queue', 'Submit')) ?> - Form->end() ?> +
+ Form->button('' . __d('queue', 'Create Job'), ['class' => 'btn btn-primary', 'escapeTitle' => false]) ?> +
+ Form->end() ?> +
+
+
diff --git a/templates/Admin/QueuedJobs/view.php b/templates/Admin/QueuedJobs/view.php index 08b3fee5..fcc2900d 100644 --- a/templates/Admin/QueuedJobs/view.php +++ b/templates/Admin/QueuedJobs/view.php @@ -7,168 +7,326 @@ use Brick\VarExporter\VarExporter; ?> - -
-

ID id) ?>

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
job_task) ?>
job_group) ?: '---' ?>
reference) ?: '---' ?>
Time->nice($queuedJob->created) ?>
- Time->nice($queuedJob->notbefore) ?> -
- QueueProgress->timeoutProgressBar($queuedJob, 18); ?> - notbefore && $queuedJob->notbefore->isFuture()) { - echo '
'; - echo method_exists($this->Time, 'relLengthOfTime') - ? $this->Time->relLengthOfTime($queuedJob->notbefore) - : $this->Time->timeAgoInWords($queuedJob->notbefore); - echo '
'; - } ?> -
- Time->nice($queuedJob->fetched) ?> - fetched) { - $interval = $queuedJob->fetched->diff($queuedJob->created); - $duration = method_exists($this->Time, 'duration') - ? $this->Time->duration($interval) - : ltrim($interval->format('%H:%I:%S'), '0:'); - echo '
'; - echo __d('queue', 'Delay') . ': ' . $duration; - echo '
'; - } ?> -
- element('Queue.ok', ['value' => $this->Time->nice($queuedJob->completed), 'ok' => (bool)$queuedJob->completed]) ?> - completed) { - $interval = $queuedJob->completed->diff($queuedJob->fetched); - $duration = method_exists($this->Time, 'duration') - ? $this->Time->duration($interval) - : ltrim($interval->format('%H:%I:%S'), '0:'); - echo '
'; - echo __d('queue', 'Duration') . ': ' . $duration; - echo '
'; - } ?> -
status) ?>
- completed && $queuedJob->fetched) { ?> - failure_message) { ?> - QueueProgress->progress($queuedJob) ?> -
- QueueProgress->progressBar($queuedJob, 18); - echo $this->QueueProgress->htmlProgressBar($queuedJob, $textProgressBar); - ?> - - Queue->failureStatus($queuedJob); ?> - - +
+

+ + #id) ?> +

+
+ completed): ?> + Html->link( + '' . __d('queue', 'Edit'), + ['action' => 'edit', $queuedJob->id], + ['class' => 'btn btn-outline-primary', 'escapeTitle' => false] + ) ?> + + Form->postLink( + '' . __d('queue', 'Clone & Re-run'), + ['action' => 'clone', $queuedJob->id], + [ + 'class' => 'btn btn-outline-success', + 'escapeTitle' => false, + 'confirm' => __d('queue', 'Sure?'), + 'block' => true, + ] + ) ?> + + Html->link( + '' . __d('queue', 'Export'), + ['action' => 'view', $queuedJob->id, '_ext' => 'json', '?' => ['download' => true]], + ['class' => 'btn btn-outline-secondary', 'escapeTitle' => false] + ) ?> + Form->postLink( + '' . __d('queue', 'Delete'), + ['action' => 'delete', $queuedJob->id], + [ + 'class' => 'btn btn-outline-danger', + 'escapeTitle' => false, + 'confirm' => __d('queue', 'Are you sure you want to delete # {0}?', $queuedJob->id), + 'block' => true, + ] + ) ?> +
+
- completed) { ?> - - +
+ +
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + +
job_task) ?>
+ job_group): ?> + job_group) ?> + + --- + +
+ reference): ?> + reference) ?> + + --- + +
Number->format($queuedJob->priority) ?>
+ workerkey): ?> + workerkey) ?> + worker_process): ?> + Html->link( + '', + ['controller' => 'QueueProcesses', 'action' => 'view', $queuedJob->worker_process->id], + ['escapeTitle' => false, 'title' => $queuedJob->worker_process->server ?: $queuedJob->worker_process->pid] + ) ?> + + + --- + +
+
+
- memory) { ?> -
: Number->format($queuedJob->memory); ?> MB
- -
- attempts ? $this->element('Queue.ok', ['value' => $this->Queue->attempts($queuedJob), 'ok' => $queuedJob->completed || $queuedJob->attempts < 1]) : '' ?> - Queue->hasFailed($queuedJob)) { - echo ' ' . $this->Form->postLink(__d('queue', 'Soft reset'), ['controller' => 'Queue', 'action' => 'resetJob', $queuedJob->id], ['confirm' => 'Sure?', 'class' => 'button button-primary btn margin btn-primary']); - } elseif (!$queuedJob->completed && $queuedJob->fetched && $queuedJob->attempts && $queuedJob->failure_message) { - echo ' ' . $this->Form->postLink(__d('queue', 'Force reset'), ['controller' => 'Queue', 'action' => 'resetJob', $queuedJob->id], ['confirm' => 'Sure? This job is currently waiting to be re-queued.', 'class' => 'button button-primary btn margin btn-primary']); - } - ?> -
- workerkey) ?> - worker_process) { ?> - [Html->link($queuedJob->worker_process->server ?: $queuedJob->worker_process->pid, ['controller' => 'QueueProcesses', 'action' => 'view', $queuedJob->worker_process->id]); ?>] - -
Number->format($queuedJob->priority) ?>
-
-
-

- data) { - $data = $queuedJob->data; - if ($data && !is_array($data)) { - $data = json_decode($queuedJob->data, true); - } - echo '
' . h(VarExporter::export($data, VarExporter::TRAILING_COMMA_IN_ARRAY)) . '
'; - } - ?> + +
+
+ +
+
+ + + + + + + + + + + + + + + + + +
Time->nice($queuedJob->created) ?>
+ notbefore): ?> + Time->nice($queuedJob->notbefore) ?> + QueueProgress->timeoutProgressBar($queuedJob, 18) ?> + notbefore->isFuture()): ?> +
+ Time, 'relLengthOfTime') + ? $this->Time->relLengthOfTime($queuedJob->notbefore) + : $this->Time->timeAgoInWords($queuedJob->notbefore) ?> +
+ + + --- + +
+ fetched): ?> + Time->nice($queuedJob->fetched) ?> +
+ fetched->diff($queuedJob->created); + $duration = method_exists($this->Time, 'duration') + ? $this->Time->duration($interval) + : ltrim($interval->format('%H:%I:%S'), '0:'); + ?> + : +
+ + --- + +
+ completed): ?> + + + Time->nice($queuedJob->completed) ?> + + Queue->duration($queuedJob); ?> + +
+ : +
+ + + --- + +
+
+ + + data): ?> +
+
+ + completed): ?> + Html->link( + '' . __d('queue', 'Edit Data'), + ['action' => 'data', $queuedJob->id], + ['class' => 'btn btn-sm btn-outline-secondary', 'escapeTitle' => false] + ) ?> + +
+
+
data;
+					if ($data && !is_array($data)) {
+						$data = json_decode($queuedJob->data, true);
+					}
+					echo h(VarExporter::export($data, VarExporter::TRAILING_COMMA_IN_ARRAY));
+					?>
+
+
+ + + + output): ?> +
+
+ +
+
+
output) ?>
+
+
+ + + + failure_message): ?> +
+
+ +
+
+ Text->autoParagraph(h($queuedJob->failure_message)) ?> +
+
+
- output) { ?> -
-
-

-
output) ?>
+ + +
+ +
+
+ +
+
+
+ completed): ?> + + + + Queue->hasFailed($queuedJob)): ?> + + + + Queue->isRequeued($queuedJob)): ?> + + + + fetched): ?> + + + + notbefore && $queuedJob->notbefore->isFuture()): ?> + + + + + + + + + + status): ?> +
status) ?>
+ +
+ + + completed && $queuedJob->fetched && !$queuedJob->failure_message): ?> +
+ : +
+ QueueProgress->progress($queuedJob) ?> +
+ QueueProgress->htmlProgressBar($queuedJob, $this->QueueProgress->progressBar($queuedJob, 18)) ?> +
+
+ + + + memory): ?> +
+ : + Number->format($queuedJob->memory) ?> MB +
+ + + +
+ : + element('Queue.ok', [ + 'value' => $this->Queue->attempts($queuedJob), + 'ok' => !$this->Queue->hasFailed($queuedJob), + 'warning' => $this->Queue->isRequeued($queuedJob), + ]) ?> +
+ + + Queue->hasFailed($queuedJob)): ?> +
+ Form->postLink( + '' . __d('queue', 'Soft Reset'), + ['controller' => 'Queue', 'action' => 'resetJob', $queuedJob->id], + [ + 'class' => 'btn btn-primary w-100', + 'escapeTitle' => false, + 'confirm' => __d('queue', 'Sure?'), + 'block' => true, + ] + ) ?> + completed && $queuedJob->fetched && $queuedJob->attempts && $queuedJob->failure_message): ?> +
+ Form->postLink( + '' . __d('queue', 'Force Reset'), + ['controller' => 'Queue', 'action' => 'resetJob', $queuedJob->id], + [ + 'class' => 'btn btn-warning w-100', + 'escapeTitle' => false, + 'confirm' => __d('queue', 'Sure? This job is currently waiting to be re-queued.'), + 'block' => true, + ] + ) ?> + +
-
- -
-
-

- failure_message ? $this->Text->autoParagraph(h($queuedJob->failure_message)) : ''; ?> -
-
+
diff --git a/templates/element/Queue/mobile_nav.php b/templates/element/Queue/mobile_nav.php new file mode 100644 index 00000000..3dd6f24a --- /dev/null +++ b/templates/element/Queue/mobile_nav.php @@ -0,0 +1,88 @@ +getRequest()->getParam('controller'); +$action = $this->getRequest()->getParam('action'); + +$isActive = function (string $c, ?array $actions = null) use ($controller, $action): string { + if ($controller !== $c) { + return ''; + } + if ($actions === null) { + return 'active'; + } + + return in_array($action, $actions, true) ? 'active' : ''; +}; +?> +
+ + + + +
+
+ +
+
diff --git a/templates/element/Queue/sidebar.php b/templates/element/Queue/sidebar.php new file mode 100644 index 00000000..634dc26d --- /dev/null +++ b/templates/element/Queue/sidebar.php @@ -0,0 +1,92 @@ +getRequest()->getParam('controller'); +$action = $this->getRequest()->getParam('action'); + +$isActive = function (string $c, ?array $actions = null) use ($controller, $action): string { + if ($controller !== $c) { + return ''; + } + if ($actions === null) { + return 'active'; + } + + return in_array($action, $actions, true) ? 'active' : ''; +}; +?> + diff --git a/templates/element/Queue/stats_card.php b/templates/element/Queue/stats_card.php new file mode 100644 index 00000000..fef96c84 --- /dev/null +++ b/templates/element/Queue/stats_card.php @@ -0,0 +1,45 @@ + ['bg' => 'bg-primary bg-opacity-10', 'text' => 'text-primary'], + 'success' => ['bg' => 'bg-success bg-opacity-10', 'text' => 'text-success'], + 'warning' => ['bg' => 'bg-warning bg-opacity-10', 'text' => 'text-warning'], + 'danger' => ['bg' => 'bg-danger bg-opacity-10', 'text' => 'text-danger'], + 'info' => ['bg' => 'bg-info bg-opacity-10', 'text' => 'text-info'], + 'secondary' => ['bg' => 'bg-secondary bg-opacity-10', 'text' => 'text-secondary'], +]; + +$classes = $colorClasses[$color] ?? $colorClasses['primary']; +?> + diff --git a/templates/element/Queue/status_badge.php b/templates/element/Queue/status_badge.php new file mode 100644 index 00000000..da1d4ef4 --- /dev/null +++ b/templates/element/Queue/status_badge.php @@ -0,0 +1,37 @@ +completed) { + $status = 'completed'; + $icon = 'check'; +} elseif ($job->failure_message) { + $status = 'failed'; + $icon = 'times'; +} elseif ($job->fetched) { + $status = 'running'; + $icon = 'spinner fa-spin'; +} elseif ($job->notbefore && $job->notbefore->isFuture()) { + $status = 'scheduled'; + $icon = 'calendar'; +} + +$statusLabels = [ + 'pending' => __d('queue', 'Pending'), + 'running' => __d('queue', 'Running'), + 'completed' => __d('queue', 'Completed'), + 'failed' => __d('queue', 'Failed'), + 'scheduled' => __d('queue', 'Scheduled'), +]; +?> + + + + diff --git a/templates/element/flash/error.php b/templates/element/flash/error.php new file mode 100644 index 00000000..182a5636 --- /dev/null +++ b/templates/element/flash/error.php @@ -0,0 +1,20 @@ + + diff --git a/templates/element/flash/info.php b/templates/element/flash/info.php new file mode 100644 index 00000000..705362a7 --- /dev/null +++ b/templates/element/flash/info.php @@ -0,0 +1,20 @@ + + diff --git a/templates/element/flash/success.php b/templates/element/flash/success.php new file mode 100644 index 00000000..df01cd58 --- /dev/null +++ b/templates/element/flash/success.php @@ -0,0 +1,20 @@ + + diff --git a/templates/element/flash/warning.php b/templates/element/flash/warning.php new file mode 100644 index 00000000..41052507 --- /dev/null +++ b/templates/element/flash/warning.php @@ -0,0 +1,20 @@ + + diff --git a/templates/element/icon.php b/templates/element/icon.php index e745f679..cf2d70e4 100644 --- a/templates/element/icon.php +++ b/templates/element/icon.php @@ -1,6 +1,6 @@ 'View', - 'edit' => 'Edit', - 'delete' => 'Del', - 'times' => 'X', - 'exclamation-triangle' => '(!)', - 'cubes' => 'Data', + 'view' => ['icon' => 'eye', 'text' => 'View'], + 'edit' => ['icon' => 'edit', 'text' => 'Edit'], + 'delete' => ['icon' => 'trash', 'text' => 'Del'], + 'times' => ['icon' => 'times', 'text' => 'X'], + 'exclamation-triangle' => ['icon' => 'exclamation-triangle', 'text' => '(!)'], + 'cubes' => ['icon' => 'database', 'text' => 'Data'], ]; if ($this->helpers()->has('Icon')) { echo $this->Icon->render($name, $options, $attributes); } else { - $title = $attributes['title'] ?? $fallbackMap[$name] ?? ucfirst($name); - echo '[' . h($fallbackMap[$name] ?? ucfirst($name)) . ']'; + $mapping = $fallbackMap[$name] ?? ['icon' => $name, 'text' => ucfirst($name)]; + $title = $attributes['title'] ?? $mapping['text']; + $class = 'fas fa-' . $mapping['icon']; + if (isset($attributes['class'])) { + $class .= ' ' . $attributes['class']; + } + $text = $mapping['text']; + echo '' . h($text) . ''; } diff --git a/templates/element/ok.php b/templates/element/ok.php index 55ee5829..057e9193 100644 --- a/templates/element/ok.php +++ b/templates/element/ok.php @@ -5,14 +5,22 @@ * @var \App\View\AppView $this * @var string $value * @var bool $ok + * @var bool $warning Optional warning state (orange/yellow) */ +$warning = $warning ?? false; ?> helpers()->has('Templating')) { + if (!isset($warning) && $this->helpers()->has('Templating')) { echo $this->Templating->ok($value, $ok); - } elseif ($this->helpers()->has('Format')) { + } elseif (!isset($warning) && $this->helpers()->has('Format')) { echo $this->Format->ok($value, $ok); } else { - echo $ok ? '' . h($value) . '' : '' . h($value) . ''; + if ($ok && !$warning) { + echo '' . h($value) . ''; + } elseif ($warning) { + echo '' . h($value) . ''; + } else { + echo '' . h($value) . ''; + } } ?> diff --git a/templates/element/search.php b/templates/element/search.php index bbe0af86..48f3a2d4 100644 --- a/templates/element/search.php +++ b/templates/element/search.php @@ -2,18 +2,52 @@ /** * @var \App\View\AppView $this * @var bool $_isSearch + * @var array $jobTypes */ ?> -
- Form->create(null, ['valueSources' => 'query']); - echo $this->Form->control('search', ['placeholder' => 'Auto-wildcard mode', 'label' => 'Search (Jobgroup/Reference)']); - echo $this->Form->control('job_task', ['empty' => ' - no filter - ']); - echo $this->Form->control('status', ['options' => \Queue\Model\Entity\QueuedJob::statusesForSearch(), 'empty' => ' - no filter - ']); - echo $this->Form->button('Filter', ['type' => 'submit']); - if (!empty($_isSearch)) { - echo $this->Html->link('Reset', ['action' => 'index'], ['class' => 'button']); - } - echo $this->Form->end(); - ?> +
+
+ +
+
+ Form->create(null, ['valueSources' => 'query', 'class' => 'row g-3']) ?> +
+ Form->control('search', [ + 'placeholder' => __d('queue', 'Auto-wildcard mode'), + 'label' => __d('queue', 'Search (Group/Reference)'), + 'class' => 'form-control', + ]) ?> +
+
+ Form->control('job_task', [ + 'options' => $jobTypes ?? [], + 'empty' => __d('queue', '-- All Tasks --'), + 'class' => 'form-select', + 'label' => __d('queue', 'Task'), + ]) ?> +
+
+ Form->control('status', [ + 'options' => \Queue\Model\Entity\QueuedJob::statusesForSearch(), + 'empty' => __d('queue', '-- All Statuses --'), + 'class' => 'form-select', + 'label' => __d('queue', 'Status'), + ]) ?> +
+
+ Form->button('' . __d('queue', 'Filter'), [ + 'type' => 'submit', + 'class' => 'btn btn-primary', + 'escapeTitle' => false, + ]) ?> + + Html->link( + '', + ['action' => 'index'], + ['class' => 'btn btn-outline-secondary', 'escapeTitle' => false, 'title' => __d('queue', 'Reset')] + ) ?> + +
+ Form->end() ?> +
diff --git a/templates/element/yes_no.php b/templates/element/yes_no.php index a0789469..6203e4fd 100644 --- a/templates/element/yes_no.php +++ b/templates/element/yes_no.php @@ -14,6 +14,10 @@ } elseif ($this->helpers()->has('Format')) { echo $this->Format->yesNo($value); } else { - echo $value ? 'Yes' : 'No'; + if ($value) { + echo 'Yes'; + } else { + echo 'No'; + } } ?> diff --git a/templates/layout/queue.php b/templates/layout/queue.php new file mode 100644 index 00000000..7878d215 --- /dev/null +++ b/templates/layout/queue.php @@ -0,0 +1,448 @@ +getRequest(); +if ($request && $request->getParam('controller') === 'Queue' && $request->getParam('action') === 'index') { + $autoRefresh = (int)Configure::read('Queue.dashboardAutoRefresh') ?: 0; +} +?> + + + + + + <?= $this->fetch('title') ? strip_tags($this->fetch('title')) . ' - ' : '' ?>Queue Admin + + + + + + + + + + + + fetch('meta') ?> + fetch('css') ?> + + + + + + +
+
+
+ Queue Admin +
+ +
+
+ element('Queue.Queue/mobile_nav') ?> +
+
+ +
+ + element('Queue.Queue/sidebar') ?> + + +
+ +
+ Flash->render() ?> +
+ + fetch('content') ?> +
+
+ + +
+
+ Queue Plugin for CakePHP + + + PHP + +
+
+ + fetch('postLink') ?> + + + + + + + fetch('script') ?> + + diff --git a/tests/TestCase/Controller/Admin/QueuedJobsControllerTest.php b/tests/TestCase/Controller/Admin/QueuedJobsControllerTest.php index d41b0220..f4770d33 100644 --- a/tests/TestCase/Controller/Admin/QueuedJobsControllerTest.php +++ b/tests/TestCase/Controller/Admin/QueuedJobsControllerTest.php @@ -5,6 +5,7 @@ use Cake\Core\Configure; use Cake\Datasource\ConnectionManager; +use Cake\Http\Exception\NotFoundException; use Cake\I18n\DateTime; use Cake\TestSuite\IntegrationTestTrait; use Laminas\Diactoros\UploadedFile; @@ -209,6 +210,119 @@ public function testViewJson() { $this->assertNotEmpty($json); } + /** + * Test clone method + * + * @return void + */ + public function testClone() { + $job = $this->createJob(); + + // Mark as completed so it can be cloned + $queuedJobs = $this->getTableLocator()->get('Queue.QueuedJobs'); + $job->fetched = new DateTime('-1 hour'); + $job->completed = new DateTime(); + $queuedJobs->saveOrFail($job); + + $countBefore = $queuedJobs->find()->count(); + + $this->post(['prefix' => 'Admin', 'plugin' => 'Queue', 'controller' => 'QueuedJobs', 'action' => 'clone', $job->id]); + + $this->assertResponseCode(302); + $this->assertRedirect(['prefix' => 'Admin', 'plugin' => 'Queue', 'controller' => 'Queue', 'action' => 'index']); + + $countAfter = $queuedJobs->find()->count(); + $this->assertSame($countBefore + 1, $countAfter, 'A new job should be created'); + } + + /** + * Test execute method (GET) + * + * @return void + */ + public function testExecute() { + Configure::write('debug', true); + + $this->get(['prefix' => 'Admin', 'plugin' => 'Queue', 'controller' => 'QueuedJobs', 'action' => 'execute']); + + $this->assertResponseCode(200); + } + + /** + * Test execute method (POST) + * + * @return void + */ + public function testExecutePost() { + Configure::write('debug', true); + + $queuedJobs = $this->getTableLocator()->get('Queue.QueuedJobs'); + $countBefore = $queuedJobs->find()->count(); + + $data = [ + 'command' => 'echo "test"', + 'amount' => 1, + 'escape' => '1', + 'log' => '0', + 'exit_code' => '', + ]; + $this->post(['prefix' => 'Admin', 'plugin' => 'Queue', 'controller' => 'QueuedJobs', 'action' => 'execute'], $data); + + $this->assertResponseCode(302); + + $countAfter = $queuedJobs->find()->count(); + $this->assertSame($countBefore + 1, $countAfter, 'A new job should be created'); + } + + /** + * Test execute method throws 404 when not in debug mode + * + * @return void + */ + public function testExecuteNotDebug() { + Configure::write('debug', false); + + $this->disableErrorHandlerMiddleware(); + $this->expectException(NotFoundException::class); + + $this->get(['prefix' => 'Admin', 'plugin' => 'Queue', 'controller' => 'QueuedJobs', 'action' => 'execute']); + } + + /** + * Test migrate method (GET) + * + * @return void + */ + public function testMigrate() { + $this->get(['prefix' => 'Admin', 'plugin' => 'Queue', 'controller' => 'QueuedJobs', 'action' => 'migrate']); + + $this->assertResponseCode(200); + } + + /** + * Test migrate method (POST) + * + * @return void + */ + public function testMigratePost() { + // Create a job with old-style task name (without plugin prefix) + $this->createJob(['job_task' => 'ProgressExample']); + + $data = [ + 'tasks' => [ + 'ProgressExample' => '1', + ], + ]; + $this->post(['prefix' => 'Admin', 'plugin' => 'Queue', 'controller' => 'QueuedJobs', 'action' => 'migrate'], $data); + + $this->assertResponseCode(302); + + $queuedJobs = $this->getTableLocator()->get('Queue.QueuedJobs'); + /** @var \Queue\Model\Entity\QueuedJob $queuedJob */ + $queuedJob = $queuedJobs->find()->where(['job_task' => 'Queue.ProgressExample'])->first(); + $this->assertNotNull($queuedJob, 'Job should be migrated to use Queue. prefix'); + } + /** * Test view method * diff --git a/tests/TestCase/View/Helper/QueueHelperTest.php b/tests/TestCase/View/Helper/QueueHelperTest.php index 6be70696..94baa09e 100644 --- a/tests/TestCase/View/Helper/QueueHelperTest.php +++ b/tests/TestCase/View/Helper/QueueHelperTest.php @@ -3,8 +3,10 @@ namespace Queue\Test\TestCase\View\Helper; +use Cake\I18n\DateTime; use Cake\TestSuite\TestCase; use Cake\View\View; +use DateInterval; use Queue\Model\Entity\QueuedJob; use Queue\View\Helper\QueueHelper; @@ -138,4 +140,114 @@ public function testSecondsToHumanReadable(): void { $this->assertSame('95400 (1d 2h 30m)', $this->QueueHelper->secondsToHumanReadable(95400)); } + /** + * @return void + */ + public function testDuration(): void { + // No completed - returns null + $queuedJob = new QueuedJob([ + 'job_task' => 'Queue.Example', + 'fetched' => new DateTime('-1 hour'), + 'completed' => null, + ]); + $this->assertNull($this->QueueHelper->duration($queuedJob)); + + // No fetched - returns null + $queuedJob = new QueuedJob([ + 'job_task' => 'Queue.Example', + 'fetched' => null, + 'completed' => new DateTime(), + ]); + $this->assertNull($this->QueueHelper->duration($queuedJob)); + + // Both present - returns duration + $queuedJob = new QueuedJob([ + 'job_task' => 'Queue.Example', + 'fetched' => new DateTime('-65 seconds'), + 'completed' => new DateTime(), + ]); + $result = $this->QueueHelper->duration($queuedJob); + $this->assertSame('1m 5s', $result); + } + + /** + * @return void + */ + public function testIsRequeued(): void { + // Not fetched - not requeued + $queuedJob = new QueuedJob([ + 'job_task' => 'Queue.Example', + 'fetched' => null, + 'attempts' => 2, + 'failure_message' => 'Error', + ]); + $this->assertFalse($this->QueueHelper->isRequeued($queuedJob)); + + // Completed - not requeued + $queuedJob = new QueuedJob([ + 'job_task' => 'Queue.Example', + 'fetched' => new DateTime('-1 hour'), + 'completed' => new DateTime(), + 'attempts' => 2, + 'failure_message' => 'Error', + ]); + $this->assertFalse($this->QueueHelper->isRequeued($queuedJob)); + + // No failure message - not requeued (just running normally) + $queuedJob = new QueuedJob([ + 'job_task' => 'Queue.Example', + 'fetched' => new DateTime('-1 hour'), + 'attempts' => 2, + 'failure_message' => null, + ]); + $this->assertFalse($this->QueueHelper->isRequeued($queuedJob)); + + // Has failure message but within retry limit - IS requeued + $queuedJob = new QueuedJob([ + 'job_task' => 'Queue.Example', + 'fetched' => new DateTime('-1 hour'), + 'attempts' => 1, + 'failure_message' => 'Error', + ]); + $this->assertTrue($this->QueueHelper->isRequeued($queuedJob)); + + // Has failure message but exceeded retry limit - not requeued (failed) + $queuedJob = new QueuedJob([ + 'job_task' => 'Queue.Example', + 'fetched' => new DateTime('-1 hour'), + 'attempts' => 999, + 'failure_message' => 'Error', + ]); + $this->assertFalse($this->QueueHelper->isRequeued($queuedJob)); + } + + /** + * @return void + */ + public function testFormatInterval(): void { + // Less than 1 second + $interval = new DateInterval('PT0S'); + $this->assertSame('< 1s', $this->QueueHelper->formatInterval($interval)); + + // 1 second + $interval = new DateInterval('PT1S'); + $this->assertSame('1s', $this->QueueHelper->formatInterval($interval)); + + // 30 seconds + $interval = new DateInterval('PT30S'); + $this->assertSame('30s', $this->QueueHelper->formatInterval($interval)); + + // 1 minute 5 seconds + $interval = new DateInterval('PT1M5S'); + $this->assertSame('1m 5s', $this->QueueHelper->formatInterval($interval)); + + // 2 hours 30 minutes + $interval = new DateInterval('PT2H30M'); + $this->assertSame('2h 30m', $this->QueueHelper->formatInterval($interval)); + + // 1 day 2 hours 30 minutes 15 seconds + $interval = new DateInterval('P1DT2H30M15S'); + $this->assertSame('1d 2h 30m 15s', $this->QueueHelper->formatInterval($interval)); + } + }