🆙 Add cms i using 🆙

This commit is contained in:
Remco
2025-11-25 22:42:56 +01:00
parent 94704e0925
commit d44196149e
35591 changed files with 3601123 additions and 0 deletions
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) Taylor Otwell
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,38 @@
<p align="center"><img width="373" height="60" src="/art/logo.svg" alt="Laravel Horizon"></p>
<p align="center">
<a href="https://github.com/laravel/horizon/actions"><img src="https://github.com/laravel/horizon/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/horizon"><img src="https://img.shields.io/packagist/dt/laravel/horizon" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/horizon"><img src="https://img.shields.io/packagist/v/laravel/horizon" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/horizon"><img src="https://img.shields.io/packagist/l/laravel/horizon" alt="License"></a>
</p>
## Introduction
Horizon provides a beautiful dashboard and code-driven configuration for your Laravel powered Redis queues. Horizon allows you to easily monitor key metrics of your queue system such as job throughput, runtime, and job failures.
All of your worker configuration is stored in a single, simple configuration file, allowing your configuration to stay in source control where your entire team can collaborate.
<p align="center">
<img src="https://laravel.com/img/docs/horizon-example.png">
</p>
## Official Documentation
Documentation for Horizon can be found on the [Laravel website](https://laravel.com/docs/horizon).
## Contributing
Thank you for considering contributing to Horizon! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
Please review [our security policy](https://github.com/laravel/horizon/security/policy) on how to report security vulnerabilities.
## License
Laravel Horizon is open-sourced software licensed under the [MIT license](LICENSE.md).
@@ -0,0 +1,22 @@
<svg width="508" height="64" viewBox="0 0 508 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
.laravel { fill: #1B1B18 } @media (prefers-color-scheme:dark) { .laravel { fill: #fff } }
.horizon { fill: #868682 } @media (prefers-color-scheme:dark) { .horizon { fill: #A1A09A } }
</style>
<rect width="64" height="64" fill="#B13BC7"/>
<path d="M19.0086 47.2138C19.0032 47.2138 18.998 47.2118 18.9939 47.2083C16.7972 45.3315 15.0341 43.0002 13.8266 40.3753C12.6179 37.7479 11.9947 34.8893 12 31.9972C12 26.4722 14.2418 21.4723 17.8585 17.8556C25.5254 10.1915 37.9016 10.0307 45.766 17.4915C53.6296 24.9531 54.118 37.3205 46.8661 45.3779C39.6161 53.4341 27.2702 54.25 19.0233 47.2192C19.0192 47.2157 19.014 47.2138 19.0086 47.2138ZM17.3894 33.2305C17.391 33.2305 17.3925 33.2299 17.3937 33.2289C19.6011 31.2708 21.1681 28.6639 25.3338 28.6639C32.0006 28.6639 32.0006 35.3305 38.6675 35.3305C44.634 35.3305 46.9114 26.6482 42.9261 22.2078C39.9599 18.9027 35.5337 16.9786 30.7673 17.3806C22.6965 18.0613 16.7056 25.1546 17.383 33.2246C17.3833 33.2279 17.3861 33.2305 17.3894 33.2305Z" fill="#FDFDFC"/>
<path class="laravel" d="M103.659 7.625H96V55.5535H118.046V48.5012H103.659V7.625Z"/>
<path class="laravel" d="M145.087 27.9603C144.109 26.3627 142.722 25.1079 140.924 24.1946C139.126 23.2817 137.316 22.8251 135.496 22.8251C133.141 22.8251 130.988 23.2703 129.035 24.1602C127.08 25.0501 125.404 26.2723 124.006 27.8235C122.607 29.3757 121.519 31.1675 120.742 33.1978C119.964 35.2297 119.576 37.3643 119.576 39.6003C119.576 41.8831 119.964 44.0283 120.742 46.0362C121.519 48.0455 122.607 49.8258 124.006 51.377C125.404 52.9292 127.08 54.15 129.035 55.0399C130.988 55.9298 133.141 56.375 135.496 56.375C137.316 56.375 139.126 55.9183 140.924 55.0059C142.722 54.0936 144.11 52.8383 145.087 51.2402V55.5539H152.346V23.6471H145.087V27.9603ZM144.421 43.5027C143.977 44.7355 143.355 45.8081 142.556 46.7209C141.756 47.6342 140.791 48.3644 139.659 48.912C138.526 49.46 137.271 49.7335 135.895 49.7335C134.518 49.7335 133.275 49.46 132.165 48.912C131.054 48.364 130.1 47.6342 129.301 46.7209C128.502 45.8081 127.891 44.7355 127.469 43.5027C127.047 42.2705 126.837 40.9693 126.837 39.6003C126.837 38.2307 127.047 36.9296 127.469 35.6973C127.89 34.465 128.502 33.3929 129.301 32.4796C130.1 31.5672 131.055 30.8361 132.165 30.2886C133.275 29.7406 134.518 29.467 135.895 29.467C137.271 29.467 138.526 29.7406 139.659 30.2886C140.791 30.8366 141.756 31.5672 142.556 32.4796C143.355 33.3929 143.977 34.465 144.421 35.6973C144.864 36.9296 145.087 38.2307 145.087 39.6003C145.087 40.9693 144.864 42.2705 144.421 43.5027Z"/>
<path class="laravel" d="M204.099 27.9603C203.121 26.3627 201.734 25.1079 199.936 24.1946C198.138 23.2817 196.328 22.8251 194.508 22.8251C192.153 22.8251 190.001 23.2703 188.047 24.1602C186.093 25.0501 184.417 26.2723 183.018 27.8235C181.619 29.3757 180.531 31.1675 179.754 33.1978C178.977 35.2297 178.589 37.3643 178.589 39.6003C178.589 41.8831 178.977 44.0283 179.754 46.0362C180.531 48.0455 181.619 49.8258 183.018 51.377C184.417 52.9292 186.092 54.15 188.047 55.0399C190 55.9298 192.153 56.375 194.508 56.375C196.328 56.375 198.138 55.9183 199.936 55.0059C201.734 54.0936 203.122 52.8383 204.099 51.2402V55.5539H211.359V23.6471H204.099V27.9603ZM203.433 43.5027C202.988 44.7355 202.367 45.8081 201.568 46.7209C200.768 47.6342 199.803 48.3644 198.67 48.912C197.538 49.46 196.283 49.7335 194.907 49.7335C193.53 49.7335 192.286 49.46 191.177 48.912C190.066 48.364 189.112 47.6342 188.313 46.7209C187.513 45.8081 186.903 44.7355 186.481 43.5027C186.058 42.2705 185.849 40.9693 185.849 39.6003C185.849 38.2307 186.058 36.9296 186.481 35.6973C186.902 34.465 187.513 33.3929 188.313 32.4796C189.112 31.5672 190.067 30.8361 191.177 30.2886C192.286 29.7406 193.53 29.467 194.907 29.467C196.283 29.467 197.538 29.7406 198.67 30.2886C199.803 30.8366 200.768 31.5672 201.568 32.4796C202.367 33.3929 202.988 34.465 203.433 35.6973C203.876 36.9296 204.099 38.2307 204.099 39.6003C204.099 40.9693 203.876 42.2705 203.433 43.5027Z"/>
<path class="laravel" d="M291 7.625H283.741V55.5535H291V7.625Z"/>
<path class="laravel" d="M158.075 55.5535H165.335V30.9901H177.79V23.6471H158.075V55.5535Z"/>
<path class="laravel" d="M240.465 23.6471L231.34 48.0804L222.216 23.6471H214.862L226.778 55.5535H235.902L247.818 23.6471H240.465Z"/>
<path class="laravel" d="M263.652 22.8265C254.762 22.8265 247.725 30.3369 247.725 39.6007C247.725 49.8416 254.536 56.375 264.582 56.375C270.205 56.375 273.795 54.1648 278.181 49.3514L273.276 45.4504C273.273 45.4542 269.574 50.4469 264.051 50.4469C257.631 50.4469 254.928 45.1262 254.928 42.3733H279.013C280.278 31.8326 273.537 22.8265 263.652 22.8265ZM254.947 36.8278C255.003 36.2138 255.839 28.7541 263.594 28.7541C271.349 28.7541 272.291 36.2128 272.345 36.8278H254.947Z"/>
<path class="horizon" d="M479.715 55.685V23.5549H484.44V31.115H484.755V55.685H479.715ZM502.71 55.685V34.643C502.71 32.207 502.101 30.3799 500.883 29.1619C499.665 27.944 497.859 27.335 495.465 27.335C493.365 27.335 491.496 27.7969 489.858 28.7209C488.262 29.6449 487.002 30.926 486.078 32.564C485.196 34.202 484.755 36.113 484.755 38.297L483.936 30.674C485.028 28.2799 486.687 26.3899 488.913 25.004C491.181 23.6179 493.743 22.9249 496.599 22.9249C499.959 22.9249 502.647 23.8909 504.663 25.823C506.721 27.713 507.75 30.212 507.75 33.32V55.685H502.71Z"/>
<path class="horizon" d="M458.28 56.315C455.13 56.315 452.358 55.58 449.964 54.11C447.57 52.6399 445.701 50.645 444.357 48.125C443.055 45.563 442.404 42.686 442.404 39.494C442.404 36.26 443.076 33.4039 444.42 30.926C445.764 28.448 447.612 26.4949 449.964 25.0669C452.358 23.6389 455.13 22.9249 458.28 22.9249C461.472 22.9249 464.244 23.6389 466.596 25.0669C468.99 26.4949 470.838 28.448 472.14 30.926C473.484 33.4039 474.156 36.26 474.156 39.494C474.156 42.686 473.484 45.563 472.14 48.125C470.838 50.645 468.99 52.6399 466.596 54.11C464.244 55.58 461.472 56.315 458.28 56.315ZM458.28 52.031C460.464 52.031 462.354 51.506 463.95 50.456C465.546 49.364 466.785 47.873 467.667 45.983C468.549 44.093 468.99 41.909 468.99 39.431C468.99 35.735 468.003 32.774 466.029 30.548C464.097 28.322 461.514 27.209 458.28 27.209C455.088 27.209 452.505 28.322 450.531 30.548C448.557 32.774 447.57 35.735 447.57 39.431C447.57 41.909 448.011 44.093 448.893 45.983C449.775 47.873 451.014 49.364 452.61 50.456C454.248 51.506 456.138 52.031 458.28 52.031Z"/>
<path class="horizon" d="M414.508 55.6849V52.0309L434.038 25.5079L435.928 27.8389H415.138V23.5549H438.826V27.2089L419.296 53.7319L417.406 51.4009H439.456V55.6849H414.508Z"/>
<path class="horizon" d="M402.872 55.685V23.5549H407.912V55.685H402.872ZM402.494 16.247V10.325H408.29V16.247H402.494Z"/>
<path class="horizon" d="M359.366 56.3151C356.216 56.3151 353.444 55.5801 351.05 54.1101C348.656 52.64 346.787 50.6451 345.443 48.125C344.141 45.563 343.49 42.6861 343.49 39.4941C343.49 36.2601 344.162 33.404 345.506 30.9261C346.85 28.448 348.698 26.495 351.05 25.067C353.444 23.639 356.216 22.925 359.366 22.925C362.558 22.925 365.33 23.639 367.682 25.067C370.076 26.495 371.924 28.448 373.226 30.9261C374.57 33.404 375.242 36.2601 375.242 39.4941C375.242 42.6861 374.57 45.563 373.226 48.125C371.924 50.6451 370.076 52.64 367.682 54.1101C365.33 55.5801 362.558 56.3151 359.366 56.3151ZM359.366 52.0311C361.55 52.0311 363.44 51.5061 365.036 50.4561C366.632 49.3641 367.871 47.8731 368.753 45.9831C369.635 44.0931 370.076 41.909 370.076 39.431C370.076 35.7351 369.089 32.774 367.115 30.548C365.183 28.3221 362.6 27.209 359.366 27.209C356.174 27.209 353.591 28.3221 351.617 30.548C349.643 32.774 348.656 35.7351 348.656 39.431C348.656 41.909 349.097 44.0931 349.979 45.9831C350.861 47.8731 352.1 49.3641 353.696 50.4561C355.334 51.5061 357.224 52.0311 359.366 52.0311Z"/>
<path class="horizon" d="M303 55.685V7.68506H308.166V55.685H303ZM333.366 55.685V7.68506H338.532V55.685H333.366ZM305.646 28.792H335.634V33.3279H305.646V28.792Z"/>
<path class="horizon" d="M380.982 55.6949V23.5649H397.574V27.6949H386.022V55.6949H380.982Z"/>
</svg>

After

Width:  |  Height:  |  Size: 7.9 KiB

@@ -0,0 +1,87 @@
{
"name": "laravel/horizon",
"description": "Dashboard and code-driven configuration for Laravel queues.",
"keywords": ["laravel", "queue"],
"license": "MIT",
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"require": {
"php": "^8.0",
"ext-json": "*",
"ext-pcntl": "*",
"ext-posix": "*",
"illuminate/contracts": "^9.21|^10.0|^11.0|^12.0",
"illuminate/queue": "^9.21|^10.0|^11.0|^12.0",
"illuminate/support": "^9.21|^10.0|^11.0|^12.0",
"nesbot/carbon": "^2.17|^3.0",
"ramsey/uuid": "^4.0",
"symfony/console": "^6.0|^7.0",
"symfony/error-handler": "^6.0|^7.0",
"symfony/polyfill-php83": "^1.28",
"symfony/process": "^6.0|^7.0"
},
"require-dev": {
"mockery/mockery": "^1.0",
"orchestra/testbench": "^7.55|^8.36|^9.15|^10.8",
"phpstan/phpstan": "^1.10|^2.0",
"predis/predis": "^1.1|^2.0|^3.0"
},
"suggest": {
"ext-redis": "Required to use the Redis PHP driver.",
"predis/predis": "Required when not using the Redis PHP driver (^1.1|^2.0|^3.0)."
},
"autoload": {
"psr-4": {
"Laravel\\Horizon\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Laravel\\Horizon\\Tests\\": "tests/",
"Workbench\\App\\": "workbench/app/",
"Workbench\\Database\\Factories\\": "workbench/database/factories/",
"Workbench\\Database\\Seeders\\": "workbench/database/seeders/"
}
},
"extra": {
"branch-alias": {
"dev-master": "6.x-dev"
},
"laravel": {
"providers": [
"Laravel\\Horizon\\HorizonServiceProvider"
],
"aliases": {
"Horizon": "Laravel\\Horizon\\Horizon"
}
}
},
"config": {
"audit": {
"block-insecure": false
},
"sort-packages": true
},
"minimum-stability": "dev",
"prefer-stable": true,
"scripts": {
"post-autoload-dump": "@prepare",
"clear": "@php vendor/bin/testbench package:purge-skeleton --ansi",
"prepare": "@php vendor/bin/testbench package:discover --ansi",
"build": "@php vendor/bin/testbench workbench:build --ansi",
"serve": [
"@build",
"@php vendor/bin/testbench serve"
],
"lint": [
"@php vendor/bin/phpstan analyse"
],
"test": [
"@php vendor/bin/phpunit"
]
}
}
@@ -0,0 +1,230 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Horizon Name
|--------------------------------------------------------------------------
|
| This name appears in notifications and in the Horizon UI. Unique names
| can be useful while running multiple instances of Horizon within an
| application, allowing you to identify the Horizon you're viewing.
|
*/
'name' => env('HORIZON_NAME'),
/*
|--------------------------------------------------------------------------
| Horizon Domain
|--------------------------------------------------------------------------
|
| This is the subdomain where Horizon will be accessible from. If this
| setting is null, Horizon will reside under the same domain as the
| application. Otherwise, this value will serve as the subdomain.
|
*/
'domain' => env('HORIZON_DOMAIN'),
/*
|--------------------------------------------------------------------------
| Horizon Path
|--------------------------------------------------------------------------
|
| This is the URI path where Horizon will be accessible from. Feel free
| to change this path to anything you like. Note that the URI will not
| affect the paths of its internal API that aren't exposed to users.
|
*/
'path' => env('HORIZON_PATH', 'horizon'),
/*
|--------------------------------------------------------------------------
| Horizon Redis Connection
|--------------------------------------------------------------------------
|
| This is the name of the Redis connection where Horizon will store the
| meta information required for it to function. It includes the list
| of supervisors, failed jobs, job metrics, and other information.
|
*/
'use' => 'default',
/*
|--------------------------------------------------------------------------
| Horizon Redis Prefix
|--------------------------------------------------------------------------
|
| This prefix will be used when storing all Horizon data in Redis. You
| may modify the prefix when you are running multiple installations
| of Horizon on the same server so that they don't have problems.
|
*/
'prefix' => env(
'HORIZON_PREFIX',
Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:'
),
/*
|--------------------------------------------------------------------------
| Horizon Route Middleware
|--------------------------------------------------------------------------
|
| These middleware will get attached onto each Horizon route, giving you
| the chance to add your own middleware to this list or change any of
| the existing middleware. Or, you can simply stick with this list.
|
*/
'middleware' => ['web'],
/*
|--------------------------------------------------------------------------
| Queue Wait Time Thresholds
|--------------------------------------------------------------------------
|
| This option allows you to configure when the LongWaitDetected event
| will be fired. Every connection / queue combination may have its
| own, unique threshold (in seconds) before this event is fired.
|
*/
'waits' => [
'redis:default' => 60,
],
/*
|--------------------------------------------------------------------------
| Job Trimming Times
|--------------------------------------------------------------------------
|
| Here you can configure for how long (in minutes) you desire Horizon to
| persist the recent and failed jobs. Typically, recent jobs are kept
| for one hour while all failed jobs are stored for an entire week.
|
*/
'trim' => [
'recent' => 60,
'pending' => 60,
'completed' => 60,
'recent_failed' => 10080,
'failed' => 10080,
'monitored' => 10080,
],
/*
|--------------------------------------------------------------------------
| Silenced Jobs
|--------------------------------------------------------------------------
|
| Silencing a job will instruct Horizon to not place the job in the list
| of completed jobs within the Horizon dashboard. This setting may be
| used to fully remove any noisy jobs from the completed jobs list.
|
*/
'silenced' => [
// App\Jobs\ExampleJob::class,
],
'silenced_tags' => [
// 'notifications',
],
/*
|--------------------------------------------------------------------------
| Metrics
|--------------------------------------------------------------------------
|
| Here you can configure how many snapshots should be kept to display in
| the metrics graph. This will get used in combination with Horizon's
| `horizon:snapshot` schedule to define how long to retain metrics.
|
*/
'metrics' => [
'trim_snapshots' => [
'job' => 24,
'queue' => 24,
],
],
/*
|--------------------------------------------------------------------------
| Fast Termination
|--------------------------------------------------------------------------
|
| When this option is enabled, Horizon's "terminate" command will not
| wait on all of the workers to terminate unless the --wait option
| is provided. Fast termination can shorten deployment delay by
| allowing a new instance of Horizon to start while the last
| instance will continue to terminate each of its workers.
|
*/
'fast_termination' => false,
/*
|--------------------------------------------------------------------------
| Memory Limit (MB)
|--------------------------------------------------------------------------
|
| This value describes the maximum amount of memory the Horizon master
| supervisor may consume before it is terminated and restarted. For
| configuring these limits on your workers, see the next section.
|
*/
'memory_limit' => 64,
/*
|--------------------------------------------------------------------------
| Queue Worker Configuration
|--------------------------------------------------------------------------
|
| Here you may define the queue worker settings used by your application
| in all environments. These supervisors and settings handle all your
| queued jobs and will be provisioned by Horizon during deployment.
|
*/
'defaults' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'maxProcesses' => 1,
'maxTime' => 0,
'maxJobs' => 0,
'memory' => 128,
'tries' => 1,
'timeout' => 60,
'nice' => 0,
],
],
'environments' => [
'production' => [
'supervisor-1' => [
'maxProcesses' => 10,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
],
],
'local' => [
'supervisor-1' => [
'maxProcesses' => 3,
],
],
],
];
@@ -0,0 +1 @@
.vjs-tree-brackets{cursor:pointer}.vjs-tree-brackets:hover{color:#1890ff}.vjs-check-controller{position:absolute;left:0}.vjs-check-controller.is-checked .vjs-check-controller-inner{background-color:#1890ff;border-color:#0076e4}.vjs-check-controller.is-checked .vjs-check-controller-inner.is-checkbox:after{transform:rotate(45deg) scaleY(1)}.vjs-check-controller.is-checked .vjs-check-controller-inner.is-radio:after{transform:translate(-50%,-50%) scale(1)}.vjs-check-controller .vjs-check-controller-inner{display:inline-block;position:relative;border:1px solid #bfcbd9;border-radius:2px;vertical-align:middle;box-sizing:border-box;width:16px;height:16px;background-color:#fff;z-index:1;cursor:pointer;transition:border-color .25s cubic-bezier(.71,-.46,.29,1.46),background-color .25s cubic-bezier(.71,-.46,.29,1.46)}.vjs-check-controller .vjs-check-controller-inner:after{box-sizing:content-box;content:"";border:2px solid #fff;border-left:0;border-top:0;height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg) scaleY(0);width:4px;transition:transform .15s cubic-bezier(.71,-.46,.88,.6) .05s;transform-origin:center}.vjs-check-controller .vjs-check-controller-inner.is-radio{border-radius:100%}.vjs-check-controller .vjs-check-controller-inner.is-radio:after{border-radius:100%;height:4px;background-color:#fff;left:50%;top:50%}.vjs-check-controller .vjs-check-controller-original{opacity:0;outline:none;position:absolute;z-index:-1;top:0;left:0;right:0;bottom:0;margin:0}.vjs-carets{position:absolute;right:0;cursor:pointer}.vjs-carets svg{transition:transform .3s}.vjs-carets:hover{color:#1890ff}.vjs-carets-close{transform:rotate(-90deg)}.vjs-tree-node{display:flex;position:relative;line-height:20px}.vjs-tree-node.has-carets{padding-left:15px}.vjs-tree-node.has-carets.has-selector,.vjs-tree-node.has-selector{padding-left:30px}.vjs-tree-node.is-highlight,.vjs-tree-node:hover{background-color:#e6f7ff}.vjs-tree-node .vjs-indent{display:flex;position:relative}.vjs-tree-node .vjs-indent-unit{width:1em}.vjs-tree-node .vjs-indent-unit.has-line{border-left:1px dashed #bfcbd9}.vjs-tree-node.dark.is-highlight,.vjs-tree-node.dark:hover{background-color:#2e4558}.vjs-node-index{position:absolute;right:100%;margin-right:4px;-webkit-user-select:none;user-select:none}.vjs-colon{white-space:pre}.vjs-comment{color:#bfcbd9}.vjs-value{word-break:break-word}.vjs-value-null,.vjs-value-undefined{color:#d55fde}.vjs-value-boolean,.vjs-value-number{color:#1d8ce0}.vjs-value-string{color:#13ce66}.vjs-tree{font-family:Monaco,Menlo,Consolas,Bitstream Vera Sans Mono,monospace;font-size:14px;text-align:left}.vjs-tree.is-virtual{overflow:auto}.vjs-tree.is-virtual .vjs-tree-node{white-space:nowrap}#alertModal{z-index:99999;background:#00000080}#alertModal svg{display:block;margin:0 auto;width:4rem;height:4rem}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,26 @@
{
"name": "laravel-horizon",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"watch": "vite build --watch"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.3",
"axios": "^1.8.2",
"bootstrap": "~5.1.3",
"chart.js": "^2.9.4",
"highlight.js": "^10.7.3",
"md5": "^2.3.0",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
"phpunserialize": "^1.3.0",
"sass": "^1.74.1",
"sql-formatter": "^4.0.2",
"vite": "^5.4.21",
"vue": "^3.5.4",
"vue-json-pretty": "^2.4.0",
"vue-router": "^4.4.4"
}
}
@@ -0,0 +1,61 @@
import axios from 'axios';
import { createApp } from 'vue/dist/vue.esm-bundler.js';
import { createRouter, createWebHistory } from 'vue-router';
import VueJsonPretty from 'vue-json-pretty';
import 'vue-json-pretty/lib/styles.css';
import Base from './base';
import Routes from './routes';
import Alert from './components/Alert.vue';
import SchemeToggler from './components/SchemeToggler.vue';
import Poll from './components/Poll.vue';
let token = document.head.querySelector("meta[name='csrf-token']");
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
if (token) {
axios.defaults.headers.common['X-CSRF-TOKEN'] = token.content;
}
const app = createApp({
data() {
return {
alert: {
type: null,
autoClose: 0,
message: '',
confirmationProceed: null,
confirmationCancel: null,
},
autoLoadsNewEntries: localStorage.autoLoadsNewEntries === '1',
};
},
});
app.config.globalProperties.$http = axios.create();
let proxyPath = window.Horizon.proxy_path;
window.Horizon.basePath = proxyPath + '/' + window.Horizon.path;
let routerBasePath = window.Horizon.basePath + '/';
if (window.Horizon.path === '' || window.Horizon.path === '/') {
routerBasePath = proxyPath + '/';
window.Horizon.basePath = proxyPath;
}
const router = createRouter({
history: createWebHistory(routerBasePath),
routes: Routes,
});
app.use(router);
app.component('vue-json-pretty', VueJsonPretty);
app.component('alert', Alert);
app.component('scheme-toggler', SchemeToggler);
app.component('poll', Poll);
app.mixin(Base);
app.mount('#horizon');
@@ -0,0 +1,76 @@
import moment from 'moment-timezone';
export default {
computed: {
Horizon() {
return Horizon;
},
},
methods: {
/**
* Format the given date with respect to timezone.
*/
formatDate(unixTime) {
return moment(unixTime * 1000).add(new Date().getTimezoneOffset() / 60);
},
/**
* Format the given date with respect to timezone.
*/
formatDateIso(date) {
return moment(date).add(new Date().getTimezoneOffset() / 60);
},
/**
* Extract the job base name.
*/
jobBaseName(name) {
if (!name.includes('\\')) return name;
var parts = name.split('\\');
return parts[parts.length - 1];
},
/**
* Autoload new entries in listing screens.
*/
autoLoadNewEntries() {
if (!this.autoLoadsNewEntries) {
this.autoLoadsNewEntries = true;
localStorage.autoLoadsNewEntries = 1;
} else {
this.autoLoadsNewEntries = false;
localStorage.autoLoadsNewEntries = 0;
}
},
/**
* Convert to human readable timestamp.
*/
readableTimestamp(timestamp) {
return this.formatDate(timestamp).format('YYYY-MM-DD HH:mm:ss');
},
/**
* Uppercase the first character of the string.
*/
upperFirst(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
},
/**
* Group array entries by a given key.
*/
groupBy(array, key) {
return array.reduce(
(grouped, entry) => ({
...grouped,
[entry[key]]: [...(grouped[entry[key]] || []), entry],
}),
{}
);
},
},
};
@@ -0,0 +1,125 @@
<script type="text/ecmascript-6">
import { Modal } from 'bootstrap';
export default {
props: ['type', 'message', 'autoClose', 'confirmationProceed', 'confirmationCancel'],
data(){
return {
timeout: null,
alertModal: null,
anotherModalOpened: document.body.classList.contains('modal-open')
}
},
mounted() {
const alertModalElement = document.getElementById('alertModal');
this.alertModal = Modal.getOrCreateInstance(alertModalElement, {
backdrop: 'static',
})
this.alertModal.show();
alertModalElement.addEventListener('hidden.bs.modal', e => {
this.$root.alert.type = null;
this.$root.alert.autoClose = false;
this.$root.alert.message = '';
this.$root.alert.confirmationProceed = null;
this.$root.alert.confirmationCancel = null;
if (this.anotherModalOpened) {
document.body.classList.add('modal-open');
}
}, this);
if (this.autoClose) {
this.timeout = setTimeout(() => {
this.close();
}, this.autoClose);
}
},
methods: {
/**
* Close the modal.
*/
close(){
clearTimeout(this.timeout);
this.alertModal.hide();
},
/**
* Confirm and close the modal.
*/
confirm(){
this.confirmationProceed();
this.close();
},
/**
* Cancel and close the modal.
*/
cancel(){
if (this.confirmationCancel) {
this.confirmationCancel();
}
this.close();
}
}
}
</script>
<template>
<div class="modal" id="alertModal" tabindex="-1" role="dialog" aria-labelledby="alertModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-body">
<p class="m-0 py-4">{{message}}</p>
</div>
<div class="modal-footer justify-content-start flex-row-reverse">
<button v-if="type == 'error'" class="btn btn-primary" @click="close">
Close
</button>
<button v-if="type == 'success'" class="btn btn-primary" @click="close">
Okay
</button>
<button v-if="type == 'confirmation'" class="btn btn-danger" @click="confirm">
Yes
</button>
<button v-if="type == 'confirmation'" class="btn" @click="cancel">
Cancel
</button>
</div>
</div>
</div>
</div>
</template>
<style>
#alertModal {
z-index: 99999;
background: rgba(0, 0, 0, 0.5);
}
#alertModal svg {
display: block;
margin: 0 auto;
width: 4rem;
height: 4rem;
}
</style>
@@ -0,0 +1,75 @@
<script type="text/ecmascript-6">
import Chart from 'chart.js';
export default {
props: ['data'],
data(){
return {
context: null,
chart:null
}
},
mounted(){
this.context = this.$refs.canvas.getContext('2d');
this.chart = new Chart(this.context, {
type: 'line',
options: {
tooltips: {
intersect: false,
},
legend: {
display: false,
},
scales: {
yAxes: [
{
ticks: {
beginAtZero: true,
callback: (value, index, values) => {
return this.data.datasets[0].label === "Seconds"
? `${value} secs`
: value;
},
},
gridLines: {
display: true
},
beforeBuildTicks: function (scale) {
var max = scale.chart.data.datasets[0].data.reduce((max, value) => value > max ? value : max)
scale.max = parseFloat(max) + parseFloat(max * 0.25);
},
}
],
xAxes: [
{
gridLines: {
display: true
},
afterTickToLabelConversion: function (data) {
var xLabels = data.ticks;
xLabels.forEach(function (labels, i) {
if (i % 6 != 0 && (i + 1) != xLabels.length) {
xLabels[i] = '';
}
});
}
},
]
}
},
data: this.data
});
},
}
</script>
<template>
<div style="position: relative;">
<canvas ref="canvas" height="120"></canvas>
</div>
</template>
@@ -0,0 +1,118 @@
<script>
export default {
data() {
return {
loading: 0,
lastExecutionTime: 0,
pollingInterval: null,
}
},
props: {
interval: {
type: Number,
default: 3,
},
keepAlive: {
type: Boolean,
default: false,
},
immediate: {
type: Boolean,
default: true,
}
},
beforeMount() {
this.updatePollingInterval();
if (this.immediate) {
this.emitPoll();
}
},
mounted() {
this.createListener();
if (!this.keepAlive) {
document.addEventListener('visibilitychange', this.visibilitychangeListener = this.changedVisibility);
}
},
beforeUnmount() {
this.removeListener();
if (this.visibilitychangeListener) {
document.removeEventListener('visibilitychange', this.visibilitychangeListener);
}
},
methods: {
emitPoll() {
if (this.loading) {
return;
}
this.loading++;
this.$emit('poll');
this.loading--;
this.lastExecutionTime = Date.now();
},
removeListener() {
if (this.poll) {
clearInterval(this.poll);
this.poll = null;
}
},
createListener() {
this.poll = setInterval(() => {
this.emitPoll();
}, this.pollingInterval);
},
updatePollingInterval() {
if (this.keepAlive) {
this.pollingInterval = this.interval * 1000;
return;
}
if (document.visibilityState === 'visible') {
this.pollingInterval = 1000 * this.interval;
} else if (document.visibilityState === 'hidden') {
// One hour...
this.pollingInterval = 1000 * 60 * 60;
}
},
changedVisibility() {
this.updatePollingInterval();
this.removeListener();
this.createListener();
// throttling
if ((Date.now() - this.lastExecutionTime) >= this.pollingInterval) {
this.emitPoll();
}
},
},
render(h) {
return null;
}
}
</script>
@@ -0,0 +1,65 @@
<script type="text/ecmascript-6">
export default {
data () {
return {
scheme: 'system'
}
},
watch: {
scheme (value) {
localStorage.setItem('scheme', value);
}
},
mounted () {
this.scheme = localStorage.getItem('scheme') ?? 'system';
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', () => this.calculateScheme())
this.calculateScheme()
},
methods: {
toggleScheme () {
if (this.scheme == 'system') {
this.scheme = 'dark'
} else if (this.scheme == 'dark') {
this.scheme = 'light'
} else {
this.scheme = 'system'
}
this.calculateScheme()
},
calculateScheme () {
const dark = document.querySelector('style[data-scheme="dark"]');
if (this.scheme == 'system') {
const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)');
dark.media = prefersDarkMode.matches ? "" : "max-width: 1px";
} else {
dark.media = this.scheme == 'dark' ? "" : "max-width: 1px";
}
}
}
}
</script>
<template>
<button class="btn btn-muted" title="Switch Theme" v-on:click.prevent="toggleScheme">
<svg v-if="scheme == 'system'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon" fill="currentColor">
<path fill-rule="evenodd" d="M2 4.25A2.25 2.25 0 014.25 2h11.5A2.25 2.25 0 0118 4.25v8.5A2.25 2.25 0 0115.75 15h-3.105a3.501 3.501 0 001.1 1.677A.75.75 0 0113.26 18H6.74a.75.75 0 01-.484-1.323A3.501 3.501 0 007.355 15H4.25A2.25 2.25 0 012 12.75v-8.5zm1.5 0a.75.75 0 01.75-.75h11.5a.75.75 0 01.75.75v7.5a.75.75 0 01-.75.75H4.25a.75.75 0 01-.75-.75v-7.5z" clip-rule="evenodd" />
</svg>
<svg v-if="scheme == 'dark'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon" fill="currentColor">
<path fill-rule="evenodd" d="M7.455 2.004a.75.75 0 01.26.77 7 7 0 009.958 7.967.75.75 0 011.067.853A8.5 8.5 0 116.647 1.921a.75.75 0 01.808.083z" clip-rule="evenodd" />
</svg>
<svg v-if="scheme == 'light'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon" fill="currentColor">
<path d="M10 2a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 0110 2zM10 15a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 0110 15zM10 7a3 3 0 100 6 3 3 0 000-6zM15.657 5.404a.75.75 0 10-1.06-1.06l-1.061 1.06a.75.75 0 001.06 1.06l1.06-1.06zM6.464 14.596a.75.75 0 10-1.06-1.06l-1.06 1.06a.75.75 0 001.06 1.06l1.06-1.06zM18 10a.75.75 0 01-.75.75h-1.5a.75.75 0 010-1.5h1.5A.75.75 0 0118 10zM5 10a.75.75 0 01-.75.75h-1.5a.75.75 0 010-1.5h1.5A.75.75 0 015 10zM14.596 15.657a.75.75 0 001.06-1.06l-1.06-1.061a.75.75 0 10-1.06 1.06l1.06 1.06zM5.404 6.464a.75.75 0 001.06-1.06l-1.06-1.06a.75.75 0 10-1.061 1.06l1.06 1.06z" />
</svg>
</button>
</template>
@@ -0,0 +1,41 @@
<script type="text/ecmascript-6">
export default {
props: ['trace'],
/**
* The component's data.
*/
data() {
return {
minimumLines: 5,
showAll: false,
};
},
computed: {
lines() {
return this.trace.slice(0, this.showAll ? 1000 : this.minimumLines);
}
}
}
</script>
<template>
<div class="table-responsive">
<table class="table mb-0">
<tbody>
<tr v-for="line in lines">
<td class="card-bg-secondary"><code>{{line}}</code></td>
</tr>
<tr v-if="! showAll">
<td class="card-bg-secondary"><a href="*" class="text-decoration-none" v-on:click.prevent="showAll = true">Show All</a></td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
</style>
@@ -0,0 +1,123 @@
import dashboard from './screens/dashboard.vue';
import monitoring from './screens/monitoring/index.vue';
import monitoringTag from './screens/monitoring/tag.vue';
import monitoringTagJobs from './screens/monitoring/tag-jobs.vue';
import metrics from './screens/metrics/index.vue';
import metricsJobs from './screens/metrics/jobs.vue';
import metricsQueues from './screens/metrics/queues.vue';
import metricsPreview from './screens/metrics/preview.vue';
import recentJobs from './screens/recentJobs/index.vue';
import recentJobsJob from './screens/recentJobs/job.vue';
import failedJobs from './screens/failedJobs/index.vue';
import failedJobsJob from './screens/failedJobs/job.vue';
import batches from './screens/batches/index.vue';
import batchesPreview from './screens/batches/preview.vue';
export default [
{ path: '/', redirect: '/dashboard' },
{
path: '/dashboard',
name: 'dashboard',
component: dashboard,
},
{
path: '/monitoring',
children: [
{
path: '',
name: 'monitoring',
component: monitoring,
},
{
path: ':tag',
component: monitoringTag,
children: [
{
path: 'jobs',
name: 'monitoring-jobs',
component: monitoringTagJobs,
props: { type: 'jobs' },
},
{
path: 'failed',
name: 'monitoring-failed',
component: monitoringTagJobs,
props: { type: 'failed' },
},
],
},
],
},
{
path: '/metrics',
redirect: '/metrics/jobs',
children: [
{
path: 'jobs',
component: metrics,
children: [{ path: '', name: 'metrics-jobs', component: metricsJobs }],
},
{
path: 'queues',
component: metrics,
children: [{ path: '', name: 'metrics-queues', component: metricsQueues }],
},
{
path: ':type/:slug',
name: 'metrics-preview',
component: metricsPreview,
},
],
},
{
path: '/jobs/:type',
children: [
{
path: '',
name: 'jobs',
component: recentJobs,
},
{
path: ':jobId',
name: 'job-preview',
component: recentJobsJob,
},
],
},
{
path: '/failed',
children: [
{
path: '',
name: 'failed-jobs',
component: failedJobs,
},
{
path: ':jobId',
name: 'failed-jobs-preview',
component: failedJobsJob,
},
],
},
{
path: '/batches',
children: [
{
path: '',
name: 'batches',
component: batches,
},
{
path: ':batchId',
name: 'batches-preview',
component: batchesPreview,
},
],
},
];
@@ -0,0 +1,200 @@
<script type="text/ecmascript-6">
export default {
/**
* The component's data.
*/
data() {
return {
ready: false,
loadingNewEntries: false,
hasNewEntries: false,
page: 1,
previousFirstId: null,
batches: [],
};
},
/**
* Prepare the component.
*/
mounted() {
document.title = "Horizon - Batches";
},
/**
* Watch these properties for changes.
*/
watch: {
'$route'() {
this.page = 1;
this.loadBatches();
},
'$root.autoLoadsNewEntries'(autoLoadsNewEntries) {
if (autoLoadsNewEntries && this.hasNewEntries) {
this.hasNewEntries = false;
}
}
},
methods: {
/**
* Load the batches.
*/
loadBatches(beforeId = '', refreshing = false) {
if (!refreshing) {
this.ready = false;
}
this.$http.get(Horizon.basePath + '/api/batches?before_id=' + beforeId)
.then(response => {
if (!this.$root.autoLoadsNewEntries && refreshing && !response.data.batches.length) {
this.ready = true;
return;
}
if (!this.$root.autoLoadsNewEntries && refreshing && this.batches.length && response.data.batches[0]?.id !== this.batches[0]?.id) {
this.hasNewEntries = true;
} else {
this.batches = response.data.batches;
}
this.ready = true;
});
},
loadNewEntries() {
this.batches = [];
this.loadBatches(0, false);
this.hasNewEntries = false;
},
/**
* Poll handler to refresh the batches at regular intervals.
*/
refreshBatchesPeriodically() {
if (this.page != 1) return;
this.loadBatches('', true);
},
/**
* Load the batches for the previous page.
*/
previous() {
this.loadBatches(
this.page == 2 ? '' : this.previousFirstId
);
this.page -= 1;
this.hasNewEntries = false;
},
/**
* Load the batches for the next page.
*/
next() {
this.previousFirstId = this.batches[0]?.id + '0';
this.loadBatches(
this.batches.slice(-1)[0]?.id
);
this.page += 1;
this.hasNewEntries = false;
}
}
}
</script>
<template>
<div>
<poll @poll="refreshBatchesPeriodically" />
<div class="card overflow-hidden">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Batches</h2>
</div>
<div v-if="!ready" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
<path d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
</svg>
<span>Loading...</span>
</div>
<div v-if="ready && batches.length == 0" class="d-flex flex-column align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<span>There aren't any batches.</span>
</div>
<table v-if="ready && batches.length > 0" class="table table-hover mb-0">
<thead>
<tr>
<th>Batch</th>
<th>Status</th>
<th class="text-end">Size</th>
<th class="text-end">Completion</th>
<th class="text-end">Created</th>
</tr>
</thead>
<tbody>
<tr v-if="hasNewEntries && !this.$root.autoLoadsNewEntries" key="newEntries" class="dontanimate">
<td colspan="100" class="text-center card-bg-secondary py-2">
<small><a href="#" v-on:click.prevent="loadNewEntries" v-if="!loadingNewEntries">Load New Entries</a></small>
<small v-if="loadingNewEntries">Loading...</small>
</td>
</tr>
<tr v-for="batch in batches" :key="batch.id">
<td>
<router-link :title="batch.id" :to="{ name: 'batches-preview', params: { batchId: batch.id }}">
{{ batch.name || batch.id }}
</router-link>
</td>
<td>
<small class="badge badge-danger badge-sm" v-if="!batch.cancelledAt && batch.failedJobs > 0 && batch.totalJobs - batch.pendingJobs < batch.totalJobs">
Failures
</small>
<small class="badge badge-success badge-sm" v-if="!batch.cancelledAt && batch.totalJobs - batch.pendingJobs == batch.totalJobs">
Finished
</small>
<small class="badge badge-secondary badge-sm" v-if="!batch.cancelledAt && batch.pendingJobs > 0 && !batch.failedJobs">
Pending
</small>
<small class="badge badge-warning badge-sm" v-if="batch.cancelledAt">
Cancelled
</small>
</td>
<td class="text-end text-muted">{{ batch.totalJobs }}</td>
<td class="text-end text-muted">{{ batch.progress }}%</td>
<td class="text-end table-fit">
{{ formatDateIso(batch.createdAt).format("YYYY-MM-DD HH:mm:ss") }}
</td>
</tr>
</tbody>
</table>
<div v-if="ready && batches.length" class="p-3 d-flex justify-content-between border-top">
<button @click="previous" class="btn btn-secondary btn-sm" :disabled="page==1">Previous</button>
<button @click="next" class="btn btn-secondary btn-sm" :disabled="batches.length < 50">Next</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,186 @@
<script type="text/ecmascript-6">
export default {
/**
* The component's data.
*/
data() {
return {
ready: false,
retrying: false,
batch: {},
failedJobs : []
};
},
/**
* Prepare the component.
*/
mounted() {
document.title = "Horizon - Batches";
},
methods: {
loadBatch(reload = true) {
if (reload) {
this.ready = false;
}
this.$http.get(Horizon.basePath + '/api/batches/' + this.$route.params.batchId)
.then(response => {
this.batch = response.data.batch;
this.failedJobs = response.data.failedJobs;
this.ready = true;
});
},
/**
* Retry the given failed job.
*/
retry(id) {
if (this.retrying) {
return;
}
this.retrying = true;
this.$http.post(Horizon.basePath + '/api/batches/retry/' + id)
.then(() => {
setTimeout(() => {
this.loadBatch(false);
this.retrying = false;
}, 3000);
});
},
}
}
</script>
<template>
<div>
<poll @poll="loadBatch(false)" />
<div class="card overflow-hidden">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0" v-if="!ready">Batch Preview</h2>
<h2 class="h6 m-0" v-if="ready">{{batch.name || batch.id}}</h2>
<button class="btn btn-primary" v-if="failedJobs.length > 0" v-on:click.prevent="retry(batch.id)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon" fill="currentColor" :class="{spin: retrying}">
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z" clip-rule="evenodd" />
</svg>
Retry Failed Jobs
</button>
</div>
<div v-if="!ready" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
<path d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
</svg>
<span>Loading...</span>
</div>
<div class="card-body card-bg-secondary" v-if="ready">
<div class="row mb-2">
<div class="col-md-2 text-muted">ID</div>
<div class="col">
{{batch.id}}
<small class="ms-1 badge badge-danger badge-sm" v-if="batch.failedJobs > 0 && batch.totalJobs - batch.pendingJobs < batch.totalJobs">
Failures
</small>
<small class="ms-1 badge badge-success badge-sm" v-if="batch.totalJobs - batch.pendingJobs == batch.totalJobs">
Finished
</small>
<small class="ms-1 badge badge-secondary badge-sm" v-if="batch.pendingJobs > 0 && !batch.failedJobs">
Pending
</small>
</div>
</div>
<div class="row mb-2" v-if="batch.name">
<div class="col-md-2 text-muted">Name</div>
<div class="col">{{batch.name}}</div>
</div>
<div class="row mb-2" v-if="batch.options.queue">
<div class="col-md-2 text-muted">Queue</div>
<div class="col">{{batch.options.queue}}</div>
</div>
<div class="row mb-2" v-if="batch.options.connection">
<div class="col-md-2 text-muted">Connection</div>
<div class="col">{{batch.options.connection}}</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Created</div>
<div class="col">{{ formatDateIso(batch.createdAt).format('YYYY-MM-DD HH:mm:ss') }}</div>
</div>
<div class="row mb-2" v-if="batch.finishedAt">
<div class="col-md-2 text-muted">Finished</div>
<div class="col">{{ formatDateIso(batch.finishedAt).format('YYYY-MM-DD HH:mm:ss') }}</div>
</div>
<div class="row mb-2" v-if="batch.cancelledAt">
<div class="col-md-2 text-muted">Cancelled</div>
<div class="col">{{ formatDateIso(batch.cancelledAt).format('YYYY-MM-DD HH:mm:ss') }}</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Total Jobs</div>
<div class="col">{{batch.totalJobs}}</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Pending Jobs</div>
<div class="col">{{batch.pendingJobs}}</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Failed Jobs</div>
<div class="col">{{batch.failedJobs}}</div>
</div>
<div class="row">
<div class="col-md-2 text-muted">Processed Jobs<br><small>(Including Failed)</small></div>
<div class="col">{{ (batch.processedJobs) }} ({{batch.progress}}%)</div>
</div>
</div>
</div>
<div class="card overflow-hidden mt-4" v-if="ready && failedJobs.length">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Failed Jobs</h2>
</div>
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Job</th>
<th class="text-end">Runtime</th>
<th class="text-end">Failed</th>
</tr>
</thead>
<tbody>
<tr v-for="failedJob in failedJobs">
<td>
<router-link :title="failedJob.name" :to="{ name: 'failed-jobs-preview', params: { jobId: failedJob.id }}">
{{ jobBaseName(failedJob.name) }}
</router-link>
</td>
<td class="text-end text-muted table-fit">
<span>{{ failedJob.failed_at && failedJob.reserved_at ? String(( failedJob.failed_at - failedJob.reserved_at ).toFixed(2))+'s' : '-' }}</span>
</td>
<td class="text-end text-muted table-fit">
{{ readableTimestamp(failedJob.failed_at) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
@@ -0,0 +1,349 @@
<script type="text/ecmascript-6">
import moment from 'moment';
export default {
components: {},
/**
* The component's data.
*/
data() {
return {
stats: {},
workers: [],
workload: [],
ready: false,
};
},
/**
* Prepare the component.
*/
mounted() {
document.title = "Horizon - Dashboard";
},
computed: {
/**
* Determine the recent job period label.
*/
recentJobsPeriod() {
return !this.ready
? 'Jobs Past Hour'
: `Jobs Past ${this.determinePeriod(this.stats.periods.recentJobs)}`;
},
/**
* Determine the recently failed job period label.
*/
failedJobsPeriod() {
return !this.ready
? 'Failed Jobs Past 7 Days'
: `Failed Jobs Past ${this.determinePeriod(this.stats.periods.failedJobs)}`;
},
},
methods: {
/**
* Load the general stats.
*/
loadStats() {
return this.$http.get(Horizon.basePath + '/api/stats')
.then(response => {
this.stats = response.data;
if (Object.values(response.data.wait)[0]) {
this.stats.max_wait_time = Object.values(response.data.wait)[0];
this.stats.max_wait_queue = Object.keys(response.data.wait)[0].split(':')[1];
}
});
},
/**
* Load the workers stats.
*/
loadWorkers() {
return this.$http.get(Horizon.basePath + '/api/masters')
.then(response => {
this.workers = response.data;
});
},
/**
* Load the workload stats.
*/
loadWorkload() {
return this.$http.get(Horizon.basePath + '/api/workload')
.then(response => {
this.workload = response.data;
});
},
/**
* Poll handler to refresh the stats at regular intervals.
*/
refreshStatsPeriodically() {
Promise.all([
this.loadStats(),
this.loadWorkers(),
this.loadWorkload(),
]).then(() => {
this.ready = true;
});
},
/**
* Count processes for the given supervisor.
*/
countProcesses(processes) {
return Object.values(processes).reduce((total, value) => total + value, 0).toLocaleString();
},
/**
* Format the Supervisor display name.
*/
superVisorDisplayName(supervisor, worker) {
return supervisor.replace(worker + ':', '');
},
/**
*
* @returns {string}
*/
humanTime(time) {
return moment.duration(time, "seconds").humanize().replace(/^(.)/g, function ($1) {
return $1.toUpperCase();
});
},
/**
* Determine the unit for the given timeframe.
*/
determinePeriod(minutes) {
return moment.duration(moment().diff(moment().subtract(minutes, "minutes"))).humanize().replace(/^An?\s/i, '').replace(/^(.)|\s(.)/g, function ($1) {
return $1.toUpperCase();
});
}
}
}
</script>
<template>
<div>
<poll @poll="refreshStatsPeriodically" :interval="5" />
<div class="card overflow-hidden">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Overview</h2>
</div>
<div class="card-bg-secondary">
<div class="d-flex">
<div class="w-25">
<div class="p-4">
<small class="text-muted fw-bold">Jobs Per Minute</small>
<p class="h4 mt-2 mb-0">
{{ stats.jobsPerMinute ? stats.jobsPerMinute.toLocaleString() : 0 }}
</p>
</div>
</div>
<div class="w-25">
<div class="p-4">
<small class="text-muted fw-bold" v-text="recentJobsPeriod"></small>
<p class="h4 mt-2 mb-0">
{{ stats.recentJobs ? stats.recentJobs.toLocaleString() : 0 }}
</p>
</div>
</div>
<div class="w-25">
<div class="p-4">
<small class="text-muted fw-bold" v-text="failedJobsPeriod"></small>
<p class="h4 mt-2 mb-0">
{{ stats.failedJobs ? stats.failedJobs.toLocaleString() : 0 }}
</p>
</div>
</div>
<div class="w-25">
<div class="p-4">
<small class="text-muted fw-bold">Status</small>
<div class="d-flex align-items-center mt-2">
<svg v-if="stats.status == 'running'" xmlns="http://www.w3.org/2000/svg" class="text-success" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="width: 1.5rem; height: 1.5rem;">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-if="stats.status == 'paused'" xmlns="http://www.w3.org/2000/svg" class="text-warning" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="width: 1.5rem; height: 1.5rem;">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.25 9v6m-4.5 0V9M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-if="stats.status == 'inactive'" xmlns="http://www.w3.org/2000/svg" class="text-danger" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="width: 1.5rem; height: 1.5rem;">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
<p class="h4 mb-0 ms-2">{{ {running: 'Active', paused: 'Paused', inactive: 'Inactive'}[stats.status] }}</p>
<small v-if="stats.status == 'running' && stats.pausedMasters > 0" class="mb-0 ms-2">({{ stats.pausedMasters }} paused)</small>
</div>
</div>
</div>
</div>
<div class="d-flex">
<div class="w-25">
<div class="p-4 mb-0">
<small class="text-muted fw-bold">Total Processes</small>
<p class="h4 mt-2">
{{ stats.processes ? stats.processes.toLocaleString() : 0 }}
</p>
</div>
</div>
<div class="w-25">
<div class="p-4 mb-0">
<small class="text-muted fw-bold">Max Wait Time</small>
<p class="mt-2 mb-0">
{{ stats.max_wait_time ? humanTime(stats.max_wait_time) : '-' }}
</p>
<small class="mt-1" v-if="stats.max_wait_queue">({{ stats.max_wait_queue }})</small>
</div>
</div>
<div class="w-25">
<div class="p-4 mb-0">
<small class="text-muted fw-bold">Max Runtime</small>
<p class="h4 mt-2">
{{ stats.queueWithMaxRuntime ? stats.queueWithMaxRuntime : '-' }}
</p>
</div>
</div>
<div class="w-25">
<div class="p-4 mb-0">
<small class="text-muted fw-bold">Max Throughput</small>
<p class="h4 mt-2">
{{ stats.queueWithMaxThroughput ? stats.queueWithMaxThroughput : '-' }}
</p>
</div>
</div>
</div>
</div>
</div>
<div class="card overflow-hidden mt-4" v-if="workload.length">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Current Workload</h2>
</div>
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Queue</th>
<th class="text-end" style="width: 120px;">Jobs</th>
<th class="text-end" style="width: 120px;">Processes</th>
<th class="text-end" style="width: 180px;">Wait</th>
</tr>
</thead>
<tbody>
<template v-for="queue in workload">
<tr>
<td :class="{ 'fw-bold': queue.split_queues }">
<span>{{ queue.name.replace(/,/g, ', ') }}</span>
</td>
<td class="text-end text-muted" :class="{ 'fw-bold': queue.split_queues }">{{ queue.length ? queue.length.toLocaleString() : 0 }}</td>
<td class="text-end text-muted" :class="{ 'fw-bold': queue.split_queues }">{{ queue.processes ? queue.processes.toLocaleString() : 0 }}</td>
<td class="text-end text-muted" :class="{ 'fw-bold': queue.split_queues }">{{ humanTime(queue.wait) }}</td>
</tr>
<tr v-for="split_queue in queue.split_queues">
<td>
<svg class="icon info-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
</svg>
<span>{{ split_queue.name.replace(/,/g, ', ') }}</span>
</td>
<td class="text-end text-muted">{{ split_queue.length ? split_queue.length.toLocaleString() : 0 }}</td>
<td class="text-end text-muted">-</td>
<td class="text-end text-muted">{{ humanTime(split_queue.wait) }}</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="card overflow-hidden mt-4" v-for="worker in workers" :key="worker.name">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">{{ worker.name }}</h2>
<svg v-if="worker.status == 'running'" xmlns="http://www.w3.org/2000/svg" class="text-success" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="width: 1.5rem; height: 1.5rem;">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-if="worker.status == 'paused'" xmlns="http://www.w3.org/2000/svg" class="text-warning" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="width: 1.5rem; height: 1.5rem;">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.25 9v6m-4.5 0V9M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Supervisor</th>
<th>Connection</th>
<th>Queues</th>
<th class="text-end" style="width: 120px;">Processes</th>
<th class="text-end" style="width: 180px;">Balancing</th>
</tr>
</thead>
<tbody>
<tr v-for="supervisor in worker.supervisors">
<td>
<svg v-if="supervisor.status == 'paused'" class="fill-warning me-1" viewBox="0 0 20 20" style="width: 1rem; height: 1rem;">
<path d="M2.93 17.07A10 10 0 1 1 17.07 2.93 10 10 0 0 1 2.93 17.07zm12.73-1.41A8 8 0 1 0 4.34 4.34a8 8 0 0 0 11.32 11.32zM7 6h2v8H7V6zm4 0h2v8h-2V6z" />
</svg>
<svg v-if="supervisor.status == 'inactive'" class="fill-danger me-1" viewBox="0 0 20 20" style="width: 1rem; height: 1rem;">
<path d="M2.93 17.07A10 10 0 1 1 17.07 2.93 10 10 0 0 1 2.93 17.07zm1.41-1.41A8 8 0 1 0 15.66 4.34 8 8 0 0 0 4.34 15.66zm9.9-8.49L11.41 10l2.83 2.83-1.41 1.41L10 11.41l-2.83 2.83-1.41-1.41L8.59 10 5.76 7.17l1.41-1.41L10 8.59l2.83-2.83 1.41 1.41z" />
</svg>
{{ superVisorDisplayName(supervisor.name, worker.name) }}
</td>
<td class="text-muted">{{ supervisor.options.connection }}</td>
<td class="text-muted">{{ supervisor.options.queue.replace(/,/g, ', ') }}</td>
<td class="text-end text-muted">{{ countProcesses(supervisor.processes) }}</td>
<td class="text-end text-muted" v-if="supervisor.options.balance">
{{ upperFirst(supervisor.options.balance) }}
</td>
<td class="text-end text-muted" v-else>
Disabled
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
@@ -0,0 +1,303 @@
<script type="text/ecmascript-6">
export default {
/**
* The component's data.
*/
data() {
return {
tagSearchPhrase: '',
searchTimeout: null,
ready: false,
loadingNewEntries: false,
hasNewEntries: false,
page: 1,
perPage: 50,
totalPages: 1,
jobs: [],
retryingJobs: [],
};
},
/**
* Prepare the component.
*/
mounted() {
document.title = "Horizon - Failed Jobs";
},
/**
* Watch these properties for changes.
*/
watch: {
'$route'() {
this.page = 1;
this.loadJobs();
},
tagSearchPhrase() {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.loadJobs();
this.refreshJobsPeriodically();
}, 500);
},
'$root.autoLoadsNewEntries'(autoLoadsNewEntries) {
if (autoLoadsNewEntries && this.hasNewEntries) {
this.hasNewEntries = false;
}
}
},
methods: {
/**
* Load the jobs of the given tag.
*/
loadJobs(starting = 0, refreshing = false) {
if (!refreshing) {
this.ready = false;
}
var tagQuery = this.tagSearchPhrase ? 'tag=' + this.tagSearchPhrase + '&' : '';
this.$http.get(Horizon.basePath + '/api/jobs/failed?' + tagQuery + 'starting_at=' + starting)
.then(response => {
if (!this.$root.autoLoadsNewEntries && refreshing && !response.data.jobs.length) {
this.ready = true;
return;
}
if (!this.$root.autoLoadsNewEntries && refreshing && this.jobs.length && response.data.jobs[0]?.id !== this.jobs[0]?.id) {
this.hasNewEntries = true;
} else {
this.jobs = response.data.jobs;
this.totalPages = Math.ceil(response.data.total / this.perPage);
}
this.ready = true;
});
},
loadNewEntries() {
this.jobs = [];
this.loadJobs(0, false);
this.hasNewEntries = false;
},
/**
* Retry the given failed job.
*/
retry(id) {
if (this.isRetrying(id)) {
return;
}
this.retryingJobs.push(id);
this.$http.post(Horizon.basePath + '/api/jobs/retry/' + id)
.then((response) => {
setTimeout(() => {
this.retryingJobs = this.retryingJobs.filter(job => job != id);
}, 5000);
}).catch(error => {
this.retryingJobs = this.retryingJobs.filter(job => job != id);
});
},
/**
* Determine if the given job is currently retrying.
*/
isRetrying(id) {
return this.retryingJobs.includes(id);
},
/**
* Determine if the given job has completed.
*/
hasCompleted(job) {
return job.retried_by.find(retry => retry.status === 'completed');
},
/**
* Determine if the given job was retried.
*/
wasRetried(job) {
return job.retried_by && job.retried_by.length;
},
/**
* Determine if the given job is a retry.
*/
isRetry(job) {
return job.payload.retry_of;
},
/**
* Construct the tooltip label for a retried job.
*/
retriedJobTooltip(job) {
let lastRetry = job.retried_by[job.retried_by.length - 1];
return `Total retries: ${job.retried_by.length}, Last retry status: ${this.upperFirst(lastRetry.status)}`;
},
/**
* Poll handler to refresh the jobs at regular intervals.
*/
refreshJobsPeriodically() {
this.loadJobs((this.page - 1) * this.perPage, true);
},
/**
* Load the jobs for the previous page.
*/
previous() {
this.loadJobs(
(this.page - 2) * this.perPage - 1
);
this.page -= 1;
this.hasNewEntries = false;
},
/**
* Load the jobs for the next page.
*/
next() {
this.loadJobs(
this.page * this.perPage - 1
);
this.page += 1;
this.hasNewEntries = false;
}
}
}
</script>
<template>
<div>
<poll @poll="refreshJobsPeriodically" />
<div class="card overflow-hidden">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Failed Jobs</h2>
<div class="form-control-with-icon">
<div class="icon-wrapper">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon">
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd" />
</svg>
</div>
<input type="text" class="form-control w-100" v-model="tagSearchPhrase" placeholder="Search Tags">
</div>
</div>
<div v-if="!ready" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
<path d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
</svg>
<span>Loading...</span>
</div>
<div v-if="ready && jobs.length == 0" class="d-flex flex-column align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<span>There aren't any failed jobs.</span>
</div>
<table v-if="ready && jobs.length > 0" class="table table-hover mb-0">
<thead>
<tr>
<th>Job</th>
<th class="text-end">Runtime</th>
<th>Failed</th>
<th class="text-end">Retry</th>
</tr>
</thead>
<tbody>
<tr v-if="hasNewEntries && !this.$root.autoLoadsNewEntries" key="newEntries" class="dontanimate">
<td colspan="100" class="text-center card-bg-secondary py-2">
<small><a href="#" v-on:click.prevent="loadNewEntries" v-if="!loadingNewEntries">Load New Entries</a></small>
<small v-if="loadingNewEntries">Loading...</small>
</td>
</tr>
<tr v-for="job in jobs" :key="job.id">
<td>
<router-link :title="job.name" :to="{ name: 'failed-jobs-preview', params: { jobId: job.id }}">{{ jobBaseName(job.name) }}</router-link>
<small class="ms-1 badge bg-secondary badge-sm"
:title="retriedJobTooltip(job)"
v-if="wasRetried(job)">
Retried
</small>
<br>
<small class="text-muted">
Queue: {{job.queue}}
| Attempts: {{ job.payload.attempts }}
<span v-if="isRetry(job)">
| Retry of
<router-link :title="job.name" :to="{ name: 'failed-jobs-preview', params: { jobId: job.payload.retry_of }}">
{{ job.payload.retry_of.split('-')[0] }}
</router-link>
</span>
<span v-if="job.payload.tags && job.payload.tags.length" class="text-break">
| Tags: {{ job.payload.tags && job.payload.tags.length ? job.payload.tags.join(', ') : '' }}
</span>
</small>
</td>
<td class="table-fit text-muted text-end">
<span>{{ job.failed_at ? String((job.failed_at - job.reserved_at).toFixed(2))+'s' : '-' }}</span>
</td>
<td class="table-fit text-muted">
{{ readableTimestamp(job.failed_at) }}
</td>
<td class="text-end table-fit">
<a href="#" title="Retry Job" @click.prevent="retry(job.id)" v-if="!hasCompleted(job)">
<svg class="fill-primary" viewBox="0 0 20 20" style="width: 1.25rem; height: 1.25rem;" :class="{spin: isRetrying(job.id)}">
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z" clip-rule="evenodd" />
</svg>
</a>
</td>
</tr>
</tbody>
</table>
<div v-if="ready && jobs.length" class="p-3 d-flex justify-content-between border-top">
<button @click="previous" class="btn btn-secondary btn-sm" :disabled="page==1">Previous</button>
<button @click="next" class="btn btn-secondary btn-sm" :disabled="page>=totalPages">Next</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,258 @@
<script type="text/ecmascript-6">
import phpunserialize from 'phpunserialize'
import StackTrace from '@/components/Stacktrace.vue'
export default {
components: {
'stack-trace': StackTrace,
},
/**
* The component's data.
*/
data() {
return {
ready: false,
retrying: false,
job: {}
};
},
/**
* Prepare the component.
*/
mounted() {
this.loadFailedJob(this.$route.params.jobId);
document.title = "Horizon - Failed Jobs";
},
methods: {
loadFailedJob(id) {
this.ready = false;
this.$http.get(Horizon.basePath + '/api/jobs/failed/' + id)
.then(response => {
this.job = response.data;
this.ready = true;
});
},
/**
* Reload the job retries.
*/
reloadRetries() {
this.$http.get(Horizon.basePath + '/api/jobs/failed/' + this.$route.params.jobId)
.then(response => {
this.job.retried_by = response.data.retried_by;
});
},
/**
* Retry the given failed job.
*/
retry(id) {
if (this.retrying) {
return;
}
this.retrying = true;
this.$http.post(Horizon.basePath + '/api/jobs/retry/' + id)
.then(() => {
setTimeout(() => {
this.reloadRetries();
this.retrying = false;
}, 3000);
});
},
/**
* Pretty print serialized job.
*
* @param data
* @returns {string}
*/
prettyPrintJob(data) {
try {
return data.command && !data.command.includes('CallQueuedClosure')
? phpunserialize(data.command) : data;
} catch (err) {
return data;
}
}
}
}
</script>
<template>
<div>
<poll @poll="reloadRetries" :immediate="false" />
<div class="card overflow-hidden">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0" v-if="!ready">Job Preview</h2>
<h2 class="h6 m-0" v-if="ready">{{job.name}}</h2>
<button class="btn btn-primary" v-on:click.prevent="retry(job.id)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon" fill="currentColor" :class="{spin: retrying}">
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z" clip-rule="evenodd" />
</svg>
Retry
</button>
</div>
<div v-if="!ready" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
<path d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
</svg>
<span>Loading...</span>
</div>
<div class="card-body card-bg-secondary" v-if="ready">
<div class="row mb-2">
<div class="col-md-2 text-muted">ID</div>
<div class="col">{{job.id}}</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Connection</div>
<div class="col">{{job.connection}}</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Queue</div>
<div class="col">{{job.queue}}</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Attempts</div>
<div class="col">{{job.payload.attempts}}</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Retries</div>
<div class="col">{{job.retried_by.length}}</div>
</div>
<div class="row mb-2" v-if="job.payload.retry_of">
<div class="col-md-2 text-muted">Retry of ID</div>
<div class="col">
<a :href="Horizon.basePath + '/failed/' + job.payload.retry_of">
{{ job.payload.retry_of }}
</a>
</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Tags</div>
<div class="col">{{ job.payload.tags && job.payload.tags.length ? job.payload.tags.join(', ') : '' }}</div>
</div>
<div class="row mb-2" v-if="prettyPrintJob(job.payload.data).batchId">
<div class="col-md-2 text-muted">Batch</div>
<div class="col">
<router-link :to="{ name: 'batches-preview', params: { batchId: prettyPrintJob(job.payload.data).batchId }}">
{{ prettyPrintJob(job.payload.data).batchId }}
</router-link>
</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Pushed</div>
<div class="col">{{ readableTimestamp(job.payload.pushedAt) }}</div>
</div>
<div class="row">
<div class="col-md-2 text-muted">Failed</div>
<div class="col">{{readableTimestamp(job.failed_at)}}</div>
</div>
</div>
</div>
<div class="card overflow-hidden mt-4" v-if="ready">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Exception</h2>
</div>
<div>
<stack-trace :trace="job.exception.split('\n')"></stack-trace>
</div>
</div>
<div class="card overflow-hidden mt-4" v-if="ready">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Exception Context</h2>
</div>
<div class="card-body code-bg text-white">
<vue-json-pretty :data="prettyPrintJob(job.context)"></vue-json-pretty>
</div>
</div>
<div class="card overflow-hidden mt-4" v-if="ready">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Data</h2>
</div>
<div class="card-body code-bg text-white">
<vue-json-pretty :data="prettyPrintJob(job.payload.data)"></vue-json-pretty>
</div>
</div>
<div class="card overflow-hidden mt-4" v-if="ready && job.retried_by.length">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Recent Retries</h2>
</div>
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Status</th>
<th>ID</th>
<th class="text-end">Retry Time</th>
</tr>
</thead>
<tbody>
<tr v-for="retry in job.retried_by">
<td>
<svg v-if="retry.status == 'completed'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="fill-success" style="width: 1.5rem; height: 1.5rem;">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
</svg>
<svg v-if="retry.status == 'reserved' || retry.status == 'pending'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="fill-warning" style="width: 1.5rem; height: 1.5rem;">
<path fill-rule="evenodd" d="M2 10a8 8 0 1116 0 8 8 0 01-16 0zm5-2.25A.75.75 0 017.75 7h.5a.75.75 0 01.75.75v4.5a.75.75 0 01-.75.75h-.5a.75.75 0 01-.75-.75v-4.5zm4 0a.75.75 0 01.75-.75h.5a.75.75 0 01.75.75v4.5a.75.75 0 01-.75.75h-.5a.75.75 0 01-.75-.75v-4.5z" clip-rule="evenodd" />
</svg>
<svg v-if="retry.status == 'failed'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="fill-danger" style="width: 1.5rem; height: 1.5rem;">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
</svg>
<span class="ms-2">{{ upperFirst(retry.status) }}</span>
</td>
<td class="table-fit">
<a v-if="retry.status == 'failed'" :href="Horizon.basePath + '/failed/'+retry.id">
{{ retry.id }}
</a>
<a v-if="retry.status == 'completed'" :href="Horizon.basePath + '/jobs/completed/'+retry.id">
{{ retry.id }}
</a>
<a v-if="retry.status == 'reserved' || retry.status == 'pending'" :href="Horizon.basePath + '/jobs/pending/'+retry.id">
{{ retry.id }}
</a>
</td>
<td class="text-end table-fit text-muted">
{{readableTimestamp(retry.retried_at)}}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
@@ -0,0 +1,37 @@
<script type="text/ecmascript-6">
export default {
/**
* Prepare the component.
*/
created() {
document.title = "Horizon - Metrics";
}
}
</script>
<template>
<div>
<div class="card overflow-hidden">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Metrics</h2>
</div>
<ul class="nav nav-pills card-bg-secondary">
<li class="nav-item">
<router-link class="nav-link text-decoration-none" active-class="active" :to="{ name: 'metrics-jobs'}" href="#">
Jobs
</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link text-decoration-none" active-class="active" :to="{ name: 'metrics-queues'}" href="#">
Queues
</router-link>
</li>
</ul>
<router-view/>
</div>
</div>
</template>
@@ -0,0 +1,79 @@
<script type="text/ecmascript-6">
export default {
components: {},
/**
* The component's data.
*/
data() {
return {
ready: false,
jobs: []
};
},
/**
* Prepare the component.
*/
mounted() {
this.loadJobs();
},
methods: {
/**
* Load the jobs.
*/
loadJobs() {
this.ready = false;
this.$http.get(Horizon.basePath + '/api/metrics/jobs')
.then(response => {
this.jobs = response.data;
this.ready = true;
});
}
}
}
</script>
<template>
<div>
<div v-if="!ready" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
<path d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
</svg>
<span>Loading...</span>
</div>
<div v-if="ready && jobs.length == 0" class="d-flex flex-column align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<span>There aren't any jobs.</span>
</div>
<table v-if="ready && jobs.length > 0" class="table table-hover mb-0">
<thead>
<tr>
<th>Job</th>
</tr>
</thead>
<tbody>
<tr v-for="job in jobs" :key="job">
<td>
<router-link class="text-decoration-none" :to="{ name: 'metrics-preview', params: { type: 'jobs', slug: job }}">
{{ job }}
</router-link>
</td>
</tr>
</tbody>
</table>
</div>
</template>
@@ -0,0 +1,139 @@
<script type="text/ecmascript-6">
import LineChart from '../../components/LineChart.vue';
export default {
components: {
LineChart
},
/**
* The component's data.
*/
data() {
return {
ready: false,
rawData: {},
metric: {}
};
},
/**
* Prepare the component.
*/
mounted() {
document.title = "Horizon - Metrics";
this.loadMetric();
},
methods: {
/**
* Load the metric.
*/
loadMetric() {
this.ready = false;
this.$http.get(Horizon.basePath + '/api/metrics/' + this.$route.params.type + '/' + encodeURIComponent(this.$route.params.slug))
.then(response => {
let data = this.prepareData(response.data);
this.rawData = response.data;
this.metric.throughPutChart = this.buildChartData(data, 'throughput', 'Times');
this.metric.runTimeChart = this.buildChartData(data, 'runtime', 'Seconds');
this.ready = true;
});
},
/**
* Prepare the response data for charts.
*/
prepareData(data) {
return Object.values(this.groupBy(data.map(value => ({
...value,
time: this.formatDate(value.time).format("MMM-D hh:mmA"),
})), 'time')).map(value => value.reduce((sum, value) => ({
runtime: parseFloat(sum.runtime) + parseFloat(value.runtime),
throughput: parseInt(sum.throughput) + parseInt(value.throughput),
time: value.time
})))
},
/**
* Build the given chart data.
*/
buildChartData(data, attribute, label) {
return {
labels: data.map(entry => entry.time),
datasets: [
{
label: label,
data: data.map(entry => entry[attribute]),
lineTension: 0,
backgroundColor: 'transparent',
pointBackgroundColor: '#fff',
pointBorderColor: '#7746ec',
borderColor: '#7746ec',
borderWidth: 2,
},
],
};
},
}
}
</script>
<template>
<div>
<div class="card overflow-hidden">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Throughput - {{$route.params.slug}}</h2>
</div>
<div v-if="!ready" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
<path d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
</svg>
<span>Loading...</span>
</div>
<div class="card-body card-bg-secondary" v-if="ready">
<p class="text-center m-0 p-5" v-if="ready && !rawData.length">
Not Enough Data
</p>
<line-chart v-if="ready && rawData.length" :data="metric.throughPutChart"/>
</div>
</div>
<div class="card overflow-hidden mt-4">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Runtime - {{$route.params.slug}}</h2>
</div>
<div v-if="!ready" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
<path d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
</svg>
<span>Loading...</span>
</div>
<div class="card-body card-bg-secondary" v-if="ready">
<p class="text-center m-0 p-5" v-if="ready && !rawData.length">
Not Enough Data
</p>
<line-chart v-if="ready && rawData.length" :data="metric.runTimeChart"/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,79 @@
<script type="text/ecmascript-6">
export default {
components: {},
/**
* The component's data.
*/
data() {
return {
ready: false,
queues: []
};
},
/**
* Prepare the component.
*/
mounted() {
this.loadQueues();
},
methods: {
/**
* Load the queues.
*/
loadQueues() {
this.ready = false;
this.$http.get(Horizon.basePath + '/api/metrics/queues')
.then(response => {
this.queues = response.data;
this.ready = true;
});
}
}
}
</script>
<template>
<div>
<div v-if="!ready" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
<path d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
</svg>
<span>Loading...</span>
</div>
<div v-if="ready && queues.length == 0" class="d-flex flex-column align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<span>There aren't any queues.</span>
</div>
<table v-if="ready && queues.length > 0" class="table table-hover mb-0">
<thead>
<tr>
<th>Queue</th>
</tr>
</thead>
<tbody>
<tr v-for="queue in queues" :key="queue">
<td>
<router-link class="text-decoration-none" :to="{ name: 'metrics-preview', params: { type: 'queues', slug: queue }}">
{{ queue }}
</router-link>
</td>
</tr>
</tbody>
</table>
</div>
</template>
@@ -0,0 +1,198 @@
<script type="text/ecmascript-6">
import { Modal } from 'bootstrap';
export default {
/**
* The component's data.
*/
data() {
return {
ready: false,
newTag: '',
addTagModal: null,
addTagModalOpened: false,
tags: []
};
},
/**
* Prepare the component.
*/
mounted() {
document.title = "Horizon - Monitoring";
},
methods: {
/**
* Load the monitored tags.
*/
loadTags() {
this.$http.get(Horizon.basePath + '/api/monitoring')
.then(response => {
this.tags = response.data;
this.ready = true;
});
},
/**
* Poll handler to refresh the tags at regular intervals.
*/
refreshTagsPeriodically() {
this.loadTags();
},
/**
* Open the modal for adding a new tag.
*/
openNewTagModal() {
this.addTagModal = Modal.getOrCreateInstance(document.getElementById('addTagModel'), {
backdrop: 'static',
});
this.addTagModal.show();
const newTagInput = document.getElementById('newTagInput');
if (newTagInput) {
newTagInput.focus();
}
},
/**
* Monitor the given tag.
*/
monitorNewTag() {
if (!this.newTag) {
const newTagInput = document.getElementById('newTagInput');
if (newTagInput) {
newTagInput.focus();
}
return;
}
this.$http.post(Horizon.basePath + '/api/monitoring', {'tag': this.newTag})
.then(response => {
if (this.addTagModal) {
this.addTagModal.hide();
}
this.tags.push({tag: this.newTag, count: 0});
this.newTag = '';
})
},
/**
* Cancel adding a new tag.
*/
cancelNewTag() {
if (this.addTagModal) {
this.addTagModal.hide();
this.addTagModal.dispose();
this.addTagModal = null;
}
this.newTag = '';
},
/**
* Stop monitoring the given tag.
*/
stopMonitoring(tag) {
this.$http.delete(Horizon.basePath + '/api/monitoring/' + encodeURIComponent(tag))
.then(() => {
this.tags = this.tags.filter(existing => existing.tag !== tag)
})
}
}
}
</script>
<template>
<div>
<poll @poll="refreshTagsPeriodically" />
<div class="card overflow-hidden">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Monitoring</h2>
<button @click="openNewTagModal" class="btn btn-primary btn-sm">Monitor Tag</button>
</div>
<div v-if="!ready" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
<path d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
</svg>
<span>Loading...</span>
</div>
<div v-if="ready && tags.length == 0" class="d-flex flex-column align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<span>You're not monitoring any tags.</span>
</div>
<table v-if="ready && tags.length > 0" class="table table-hover mb-0">
<thead>
<tr>
<th>Tag</th>
<th class="text-end">Jobs</th>
<th class="text-end"></th>
</tr>
</thead>
<tbody>
<tr v-for="tag in tags">
<td>
<router-link :to="{ name: 'monitoring-jobs', params: { tag:tag.tag }}" href="#">
{{ tag.tag }}
</router-link>
</td>
<td class="text-end text-muted">{{ tag.count }}</td>
<td class="text-end">
<a href="#" @click="stopMonitoring(tag.tag)" class="control-action" title="Stop Monitoring">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
</svg>
</a>
</td>
</tr>
</tbody>
</table>
</div>
<div class="modal" id="addTagModel" tabindex="-1" role="dialog" aria-labelledby="alertModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">Monitor New Tag</div>
<div class="modal-body">
<input type="text" class="form-control" placeholder="App\Models\User:6352"
v-on:keyup.enter="monitorNewTag"
v-model="newTag"
id="newTagInput">
</div>
<div class="modal-footer justify-content-start flex-row-reverse">
<button class="btn btn-primary" @click="monitorNewTag">
Monitor
</button>
<button class="btn" @click="cancelNewTag">
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,73 @@
<template>
<tr>
<td>
<router-link :title="job.name" :to="{ name: 'job-preview', params: { jobId: job.id, type: $parent.type }}">
{{ jobBaseName(job.name) }}
</router-link>
<small class="ms-1 badge bg-secondary badge-sm" :title="`Delayed for ${delayed}`"
v-if="delayed && (job.status == 'reserved' || job.status == 'pending')">
Delayed
</small>
<br>
<small class="text-muted">
Queue: {{job.queue}}
<span v-if="job.payload.tags.length">
| Tags: {{ job.payload.tags && job.payload.tags.length ? job.payload.tags.slice(0,3).join(', ') : '' }}<span class="text-secondary" v-if="job.payload.tags.length > 3"> +{{ job.payload.tags.length - 3 }} more</span>
</span>
</small>
</td>
<td class="table-fit text-muted">
{{ readableTimestamp(job.payload.pushedAt) }}
</td>
<td v-if="$parent.type == 'jobs'" class="table-fit text-muted">
{{ job.completed_at ? readableTimestamp(job.completed_at) : '-' }}
</td>
<td v-if="$parent.type == 'jobs'" class="table-fit text-muted">
<span>{{ job.completed_at ? (job.completed_at - job.reserved_at).toFixed(2)+'s' : '-' }}</span>
</td>
<td v-if="$parent.type == 'failed'" class="table-fit text-muted">
{{ readableTimestamp(job.failed_at) }}
</td>
</tr>
</template>
<script type="text/ecmascript-6">
import phpunserialize from 'phpunserialize'
import moment from 'moment-timezone';
export default {
props: {
job: {
type: Object,
required: true
}
},
computed: {
unserialized() {
try {
return phpunserialize(this.job.payload.data.command);
}catch(err){
//
}
},
delayed() {
if (this.unserialized && this.unserialized.delay) {
return moment.tz(this.unserialized.delay.date, this.unserialized.delay.timezone)
.fromNow(true);
}
return null;
},
},
}
</script>
@@ -0,0 +1,187 @@
<script type="text/ecmascript-6">
import JobRow from './job-row.vue';
export default {
props: ['type'],
/**
* The component's data.
*/
data() {
return {
ready: false,
loadingNewEntries: false,
hasNewEntries: false,
page: 1,
perPage: 50,
totalPages: 1,
jobs: []
};
},
/**
* Components
*/
components: {
JobRow,
},
/**
* Prepare the component.
*/
mounted() {
document.title = "Horizon - Monitoring";
this.loadJobs(this.$route.params.tag);
},
/**
* Watch these properties for changes.
*/
watch: {
'$route'() {
this.page = 1;
this.loadJobs(this.$route.params.tag);
},
'$root.autoLoadsNewEntries'(autoLoadsNewEntries) {
if (autoLoadsNewEntries && this.hasNewEntries) {
this.hasNewEntries = false;
}
}
},
methods: {
/**
* Load the jobs of the given tag.
*/
loadJobs(tag, starting = 0, refreshing = false) {
if (!refreshing) {
this.ready = false;
}
tag = this.type == 'failed' ? 'failed:' + tag : tag;
this.$http.get(Horizon.basePath + '/api/monitoring/' + encodeURIComponent(tag) + '?starting_at=' + starting + '&limit=' + this.perPage + '&tag=' + encodeURIComponent(tag))
.then(response => {
if (!this.$root.autoLoadsNewEntries && refreshing && this.jobs.length && response.data.jobs[0]?.id !== this.jobs[0]?.id) {
this.hasNewEntries = true;
} else {
this.jobs = response.data.jobs;
this.totalPages = Math.ceil(response.data.total / this.perPage);
}
this.ready = true;
});
},
/**
* Load new entries.
*/
loadNewEntries() {
this.jobs = [];
this.loadJobs(this.$route.params.tag, 0, false);
this.hasNewEntries = false;
},
/**
* Poll handler to refresh the jobs at regular intervals.
*/
refreshJobsPeriodically() {
if (this.page != 1) {
return;
}
this.loadJobs(this.$route.params.tag, 0, true);
},
/**
* Load the jobs for the previous page.
*/
previous() {
this.loadJobs(this.$route.params.tag,
(this.page - 2) * this.perPage
);
this.page -= 1;
this.hasNewEntries = false;
},
/**
* Load the jobs for the next page.
*/
next() {
this.loadJobs(this.$route.params.tag,
this.page * this.perPage
);
this.page += 1;
this.hasNewEntries = false;
}
}
}
</script>
<template>
<div>
<poll @poll="refreshJobsPeriodically" />
<div v-if="!ready" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
<path d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
</svg>
<span>Loading...</span>
</div>
<div v-if="ready && jobs.length == 0" class="d-flex flex-column align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<span>There aren't any jobs for this tag.</span>
</div>
<table v-if="ready && jobs.length > 0" class="table table-hover mb-0">
<thead>
<tr>
<th>Job</th>
<th>Queued</th>
<th v-if="type == 'jobs'">Completed</th>
<th class="text-end" v-if="type == 'jobs'">Runtime</th>
<th class="text-end" v-if="type == 'failed'">Failed</th>
</tr>
</thead>
<tbody>
<tr v-if="hasNewEntries && !this.$root.autoLoadsNewEntries" key="newEntries" class="dontanimate">
<td colspan="100" class="text-center card-bg-secondary py-2">
<small><a href="#" v-on:click.prevent="loadNewEntries" v-if="!loadingNewEntries">Load New Entries</a></small>
<small v-if="loadingNewEntries">Loading...</small>
</td>
</tr>
<component v-for="job in jobs" :key="job.id" :job="job" is="job-row">
</component>
</tbody>
</table>
<div v-if="ready && jobs.length" class="p-3 d-flex justify-content-between border-top">
<button @click="previous" class="btn btn-secondary btn-sm" :disabled="page==1">Previous</button>
<button @click="next" class="btn btn-secondary btn-sm" :disabled="page>=totalPages">Next</button>
</div>
</div>
</template>
@@ -0,0 +1,30 @@
<script type="text/ecmascript-6">
export default {}
</script>
<template>
<div>
<div class="card overflow-hidden">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Recent Jobs for "{{ $route.params.tag }}"</h2>
</div>
<ul class="nav nav-pills card-bg-secondary">
<li class="nav-item">
<router-link class="nav-link text-decoration-none" active-class="active" :to="{ name: 'monitoring-jobs', params: { tag:$route.params.tag }}" href="#">
Recent Jobs
</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link text-decoration-none" active-class="active" :to="{ name: 'monitoring-failed', params: { tag:$route.params.tag }}" href="#">
Failed Jobs
</router-link>
</li>
</ul>
<router-view/>
</div>
</div>
</template>
@@ -0,0 +1,208 @@
<script type="text/ecmascript-6">
import JobRow from './job-row.vue';
export default {
/**
* The component's data.
*/
data() {
return {
ready: false,
loadingNewEntries: false,
hasNewEntries: false,
page: 1,
perPage: 50,
totalPages: 1,
jobs: []
};
},
/**
* Components
*/
components: {
JobRow,
},
/**
* Prepare the component.
*/
mounted() {
this.updatePageTitle();
this.loadJobs();
},
/**
* Watch these properties for changes.
*/
watch: {
'$route'() {
this.updatePageTitle();
this.page = 1;
this.loadJobs();
},
'$root.autoLoadsNewEntries'(autoLoadsNewEntries) {
if (autoLoadsNewEntries && this.hasNewEntries) {
this.hasNewEntries = false;
}
}
},
methods: {
/**
* Load the jobs of the given tag.
*/
loadJobs(starting = -1, refreshing = false) {
if (!refreshing) {
this.ready = false;
}
this.$http.get(Horizon.basePath + '/api/jobs/' + this.$route.params.type + '?starting_at=' + starting + '&limit=' + this.perPage)
.then(response => {
if (!this.$root.autoLoadsNewEntries && refreshing && this.jobs.length && response.data.jobs[0]?.id !== this.jobs[0]?.id) {
this.hasNewEntries = true;
} else {
this.jobs = response.data.jobs;
this.totalPages = Math.ceil(response.data.total / this.perPage);
}
this.ready = true;
});
},
loadNewEntries() {
this.jobs = [];
this.loadJobs(-1, false);
this.hasNewEntries = false;
},
/**
* Poll handler to refresh the jobs at regular intervals.
*/
refreshJobsPeriodically() {
if (this.page != 1) {
return;
}
this.loadJobs(-1, true);
},
/**
* Load the jobs for the previous page.
*/
previous() {
this.loadJobs(
(this.page - 2) * this.perPage - 1
);
this.page -= 1;
this.hasNewEntries = false;
},
/**
* Load the jobs for the next page.
*/
next() {
this.loadJobs(
this.page * this.perPage - 1
);
this.page += 1;
this.hasNewEntries = false;
},
/**
* Update the page title.
*/
updatePageTitle() {
document.title = this.$route.params.type == 'pending'
? 'Horizon - Pending Jobs'
: (
this.$route.params.type == 'silenced'
? 'Horizon - Silenced Jobs'
: 'Horizon - Completed Jobs'
);
}
}
}
</script>
<template>
<div>
<poll @poll="refreshJobsPeriodically" />
<div class="card overflow-hidden">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0" v-if="$route.params.type == 'pending'">Pending Jobs</h2>
<h2 class="h6 m-0" v-if="$route.params.type == 'completed'">Completed Jobs</h2>
<h2 class="h6 m-0" v-if="$route.params.type == 'silenced'">Silenced Jobs</h2>
</div>
<div v-if="!ready"
class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
<path
d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
</svg>
<span>Loading...</span>
</div>
<div v-if="ready && jobs.length == 0"
class="d-flex flex-column align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<span v-if="$route.params.type == 'pending'">There aren't any pending jobs.</span>
<span v-else-if="$route.params.type == 'completed'">There aren't any completed jobs.</span>
<span v-else-if="$route.params.type == 'silenced'">There aren't any silenced jobs.</span>
<span v-else>There aren't any jobs.</span>
</div>
<table v-if="ready && jobs.length > 0" class="table table-hover mb-0">
<thead>
<tr>
<th>Job</th>
<th v-if="$route.params.type=='pending'" class="text-end">Queued</th>
<th v-if="$route.params.type=='completed' || $route.params.type=='silenced'">Queued</th>
<th v-if="$route.params.type=='completed' || $route.params.type=='silenced'">Completed</th>
<th v-if="$route.params.type=='completed' || $route.params.type=='silenced'" class="text-end">Runtime</th>
</tr>
</thead>
<tbody>
<tr v-if="hasNewEntries && !this.$root.autoLoadsNewEntries" key="newEntries" class="dontanimate">
<td colspan="100" class="text-center card-bg-secondary py-1">
<small><a href="#" v-on:click.prevent="loadNewEntries" v-if="!loadingNewEntries">Load New Entries</a></small>
<small v-if="loadingNewEntries">Loading...</small>
</td>
</tr>
<component v-for="job in jobs" :key="job.id" :job="job" is="job-row">
</component>
</tbody>
</table>
<div v-if="ready && jobs.length" class="p-3 d-flex justify-content-between border-top">
<button @click="previous" class="btn btn-secondary btn-sm" :disabled="page==1">Previous</button>
<button @click="next" class="btn btn-secondary btn-sm" :disabled="page>=totalPages">Next</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,73 @@
<template>
<tr>
<td>
<router-link :title="job.name" :to="{ name: 'job-preview', params: { jobId: job.id, type: $route.params.type }}">
{{ jobBaseName(job.name) }}
</router-link>
<small class="ms-1 badge bg-secondary badge-sm"
:title="`Delayed for ${delayed}`"
v-if="delayed && (job.status == 'reserved' || job.status == 'pending')">
Delayed
</small>
<br>
<small class="text-muted">
Queue: {{job.queue}}
<span v-if="job.payload.tags && job.payload.tags.length" class="text-break">
| Tags: {{ job.payload.tags && job.payload.tags.length ? job.payload.tags.slice(0,3).join(', ') : '' }}<span class="text-secondary" v-if="job.payload.tags.length > 3"> +{{ job.payload.tags.length - 3 }} more</span>
</span>
</small>
</td>
<td class="table-fit text-muted">
{{ readableTimestamp(job.payload.pushedAt) }}
</td>
<td v-if="$route.params.type=='completed' || $route.params.type=='silenced'" class="table-fit text-muted">
{{ readableTimestamp(job.completed_at) }}
</td>
<td v-if="$route.params.type=='completed' || $route.params.type=='silenced'" class="table-fit text-end text-muted">
<span>{{ job.completed_at ? (job.completed_at - job.reserved_at).toFixed(2)+'s' : '-' }}</span>
</td>
</tr>
</template>
<script type="text/ecmascript-6">
import phpunserialize from 'phpunserialize'
import moment from 'moment-timezone';
export default {
props: {
job: {
type: Object,
required: true
}
},
computed: {
unserialized() {
try {
return phpunserialize(this.job.payload.data.command);
}catch(err){
//
}
},
delayed() {
if (this.unserialized && this.unserialized.delay && this.unserialized.delay.date) {
return moment.tz(this.unserialized.delay.date, this.unserialized.delay.timezone)
.fromNow(true);
} else if (this.unserialized && this.unserialized.delay) {
return this.formatDate(this.job.payload.pushedAt).add(this.unserialized.delay, 'seconds')
.fromNow(true);
}
return null;
},
},
}
</script>
@@ -0,0 +1,173 @@
<template>
<div>
<div class="card overflow-hidden">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0" v-if="!ready">Job Preview</h2>
<h2 class="h6 m-0" v-if="ready">{{job.name}}</h2>
<a data-bs-toggle="collapse" href="#collapseDetails" role="button">
Collapse
</a>
</div>
<div v-if="!ready" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
<path d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
</svg>
<span>Loading...</span>
</div>
<div class="card-body card-bg-secondary collapse show" id="collapseDetails" v-if="ready">
<div class="row mb-2">
<div class="col-md-2 text-muted">ID</div>
<div class="col">{{job.id}}</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Connection</div>
<div class="col">{{job.connection}}</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Queue</div>
<div class="col">{{job.queue}}</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Pushed</div>
<div class="col">{{ readableTimestamp(job.payload.pushedAt) }}</div>
</div>
<div class="row mb-2" v-if="prettyPrintJob(job.payload.data).batchId">
<div class="col-md-2 text-muted">Batch</div>
<div class="col">
<router-link :to="{ name: 'batches-preview', params: { batchId: prettyPrintJob(job.payload.data).batchId }}">
{{ prettyPrintJob(job.payload.data).batchId }}
</router-link>
</div>
</div>
<div class="row mb-2" v-if="delayed">
<div class="col-md-2 text-muted">Delayed Until</div>
<div class="col">{{delayed}}</div>
</div>
<div class="row">
<div class="col-md-2 text-muted">Completed</div>
<div class="col" v-if="job.completed_at">{{readableTimestamp(job.completed_at)}}</div>
<div class="col" v-else>-</div>
</div>
</div>
</div>
<div class="card overflow-hidden mt-4" v-if="ready">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Data</h2>
<a data-bs-toggle="collapse" href="#collapseData" role="button">
Collapse
</a>
</div>
<div class="card-body code-bg text-white collapse show" id="collapseData">
<vue-json-pretty :data="prettyPrintJob(job.payload.data)"></vue-json-pretty>
</div>
</div>
<div class="card overflow-hidden mt-4" v-if="ready && job.payload.tags.length">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Tags</h2>
<a data-bs-toggle="collapse" href="#collapseTags" role="button">
Collapse
</a>
</div>
<div class="card-body code-bg text-white collapse show" id="collapseTags">
<vue-json-pretty :data="job.payload.tags"></vue-json-pretty>
</div>
</div>
</div>
</template>
<script type="text/ecmascript-6">
import phpunserialize from 'phpunserialize';
import moment from 'moment-timezone';
import StackTrace from './../../components/Stacktrace.vue';
export default {
components: {
'stack-trace': StackTrace,
},
data() {
return {
ready: false,
job: {}
};
},
computed: {
unserialized() {
return phpunserialize(this.job.payload.data.command);
},
delayed() {
let unserialized;
try {
unserialized = phpunserialize(this.job.payload.data.command);
}catch(err){
//
}
if (unserialized && unserialized.delay && unserialized.delay.date) {
return moment.tz(unserialized.delay.date, unserialized.delay.timezone)
.local()
.format('YYYY-MM-DD HH:mm:ss');
} else if (unserialized && unserialized.delay) {
return this.formatDate(this.job.payload.pushedAt).add(unserialized.delay, 'seconds')
.local()
.format('YYYY-MM-DD HH:mm:ss');
}
return null;
},
},
mounted() {
this.loadJob(this.$route.params.jobId);
document.title = "Horizon - Job Detail";
},
methods: {
/**
* Load a job by the given ID.
*/
loadJob(id) {
this.ready = false;
this.$http.get(Horizon.basePath + '/api/jobs/' + id)
.then(response => {
this.job = response.data;
this.ready = true;
});
},
/**
* Pretty print serialized job.
*/
prettyPrintJob(data) {
try {
return data.command && !data.command.includes('CallQueuedClosure')
? phpunserialize(data.command) : data;
} catch (err) {
return data;
}
}
}
}
</script>
@@ -0,0 +1,68 @@
$white: #ffffff;
$black: #000000;
$gray-50: #f9fafb;
$gray-100: #f3f4f6;
$gray-200: #e5e7eb;
$gray-300: #d1d5db;
$gray-400: #9ca3af;
$gray-500: #6b7280;
$gray-600: #4b5563;
$gray-700: #374151;
$gray-800: #1f2937;
$gray-900: #111827;
$red-50: #fef2f2;
$red-100: #fee2e2;
$red-200: #fecaca;
$red-300: #fca5a5;
$red-400: #f87171;
$red-500: #ef4444;
$red-600: #dc2626;
$red-700: #b91c1c;
$red-800: #991b1b;
$red-900: #7f1d1d;
$amber-50: #fffbeb;
$amber-100: #fef3c7;
$amber-200: #fde68a;
$amber-300: #fcd34d;
$amber-400: #fbbf24;
$amber-500: #f59e0b;
$amber-600: #d97706;
$amber-700: #b45309;
$amber-800: #92400e;
$amber-900: #78350f;
$emerald-50: #ecfdf5;
$emerald-100: #d1fae5;
$emerald-200: #a7f3d0;
$emerald-300: #6ee7b7;
$emerald-400: #34d399;
$emerald-500: #10b981;
$emerald-600: #059669;
$emerald-700: #047857;
$emerald-800: #065f46;
$emerald-900: #064e3b;
$blue-50: #eff6ff;
$blue-100: #dbeafe;
$blue-200: #bfdbfe;
$blue-300: #93c5fd;
$blue-400: #60a5fa;
$blue-500: #3b82f6;
$blue-600: #2563eb;
$blue-700: #1d4ed8;
$blue-800: #1e40af;
$blue-900: #1e3a8a;
$violet-50: #f5f3ff;
$violet-100: #ede9fe;
$violet-200: #ddd6fe;
$violet-300: #c4b5fd;
$violet-400: #a78bfa;
$violet-500: #8b5cf6;
$violet-600: #7c3aed;
$violet-700: #6d28d9;
$violet-800: #5b21b6;
$violet-900: #4c1d95;
@@ -0,0 +1,360 @@
@import 'syntaxhighlight';
@import 'bootstrap';
body {
padding-bottom: 20px;
}
.container {
max-width: 1440px;
}
html {
min-width: 1140px;
}
[v-cloak] {
display: none;
}
svg.icon {
width: 1rem;
height: 1rem;
}
.header {
border-bottom: solid 1px $header-border-color;
.logo {
text-decoration: none;
color: $logo-color;
svg {
width: 2rem;
height: 2rem;
}
}
}
.sidebar .nav-item {
a {
color: $sidebar-nav-color;
padding: 0.5rem 0.75rem;
margin-bottom: 4px;
border-radius: $border-radius-lg;
svg {
width: 1.25rem;
height: 1.25rem;
margin-right: 15px;
fill: $sidebar-nav-icon-color;
}
&:hover {
background-color: $sidebar-nav-hover-bg;
color: $sidebar-nav-hover-color;
}
&.active {
background-color: $sidebar-nav-active-bg;
color: $sidebar-nav-active-color;
svg {
fill: $sidebar-nav-active-icon-color;
}
}
}
}
.card {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
border: none;
.bottom-radius {
border-bottom-left-radius: $card-border-radius;
border-bottom-right-radius: $card-border-radius;
}
.card-header {
padding-top: 0.7rem;
padding-bottom: 0.7rem;
background-color: $card-cap-bg;
border-bottom: none;
min-height: 60px;
.btn-group {
.btn {
padding: 0.2rem 0.5rem;
}
}
.form-control-with-icon {
position: relative;
.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
left: 0.75rem;
bottom: 0;
.icon {
fill: $text-muted;
}
}
.form-control {
padding-left: 2.25rem;
font-size: 0.875rem;
border-radius: 9999px;
}
}
}
.table {
th,
td {
padding: 0.75rem 1.25rem;
}
&.table-sm {
th,
td {
padding: 1rem 1.25rem;
}
}
th {
background-color: $table-headers-color;
font-size: 0.875rem;
padding: 0.5rem 1.25rem;
border-bottom: 0;
}
&:not(.table-borderless) {
td {
border-top: 1px solid $table-border-color;
}
}
&.penultimate-column-right {
th:nth-last-child(2),
td:nth-last-child(2) {
text-align: right;
}
}
th.table-fit,
td.table-fit {
width: 1%;
white-space: nowrap;
}
}
}
.fill-text-color {
fill: $body-color;
}
.fill-danger {
fill: $danger;
}
.fill-warning {
fill: $warning;
}
.fill-info {
fill: $info;
}
.fill-success {
fill: $success;
}
.fill-primary {
fill: $primary;
}
button:hover {
.fill-primary {
fill: #fff;
}
}
.btn-outline-primary.active {
.fill-primary {
fill: $body-bg;
}
}
.btn-outline-primary:not(:disabled):not(.disabled).active:focus {
box-shadow: none !important;
}
.btn-muted {
color: $btn-muted-color;
background: $btn-muted-bg;
&:hover,
&:focus {
color: $btn-muted-hover-color;
background: $btn-muted-hover-bg;
}
&.active {
color: $btn-muted-active-color;
background: $btn-muted-active-bg;
}
}
.badge-secondary {
background: $badge-secondary-bg;
color: $badge-secondary-color;
}
.badge-success {
background: $badge-success-bg;
color: $badge-success-color;
}
.badge-info {
background: $badge-info-bg;
color: $badge-info-color;
}
.badge-warning {
background: $badge-warning-bg;
color: $badge-warning-color;
}
.badge-danger {
background: $badge-danger-bg;
color: $badge-danger-color;
}
.control-action {
svg {
fill: $control-action-icon-color;
width: 1.2rem;
height: 1.2rem;
&:hover {
fill: $control-action-icon-hover;
}
}
}
.info-icon {
fill: $control-action-icon-color;
}
@-webkit-keyframes spin {
from {
-ms-transform: rotate(0deg);
-moz-transform: rotate(0deg);
-webkit-transform: rotate(0deg);
-o-transform: rotate(0deg);
transform: rotate(0deg);
}
to {
-ms-transform: rotate(360deg);
-moz-transform: rotate(360deg);
-webkit-transform: rotate(360deg);
-o-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes spin {
from {
-ms-transform: rotate(0deg);
-moz-transform: rotate(0deg);
-webkit-transform: rotate(0deg);
-o-transform: rotate(0deg);
transform: rotate(0deg);
}
to {
-ms-transform: rotate(360deg);
-moz-transform: rotate(360deg);
-webkit-transform: rotate(360deg);
-o-transform: rotate(360deg);
transform: rotate(360deg);
}
}
.spin {
-webkit-animation: spin 2s linear infinite;
-moz-animation: spin 2s linear infinite;
-ms-animation: spin 2s linear infinite;
-o-animation: spin 2s linear infinite;
animation: spin 2s linear infinite;
}
.card {
.nav-pills {
background: $card-cap-bg;
.nav-link {
font-size: 0.9rem;
border-radius: 0;
padding: 0.75rem 1.25rem;
color: $pill-link;
&:hover,
&:focus {
color: $pill-link-hover;
}
&.active {
background: none;
color: $pill-link-active;
border-bottom: solid 2px $pill-link-active;
}
}
}
}
.list-enter-active:not(.dontanimate) {
transition: background 1s linear;
}
.list-enter:not(.dontanimate),
.list-leave-to:not(.dontanimate) {
background: $new-entries-bg;
}
.code-bg .list-enter:not(.dontanimate),
.code-bg .list-leave-to:not(.dontanimate) {
background: $new-code-entries-bg;
}
.card table {
td {
vertical-align: middle !important;
}
}
.card-bg-secondary {
background: $card-bg-secondary;
}
.code-bg {
background: $code-bg;
}
.disabled-watcher {
padding: 0.75rem;
color: #fff;
background: $danger;
}
.badge-sm {
font-size: 0.75rem;
}
.table > :not(:first-child) {
border-top: none;
}
@@ -0,0 +1,113 @@
@import 'colors';
$font-family-base: Figtree, sans-serif;
$font-weight-bold: 600;
$font-size-base: 1rem;
$badge-font-size: 0.875rem;
$link-decoration: none;
$link-hover-decoration: underline;
$primary: $violet-500;
$secondary: $gray-500;
$success: $emerald-500;
$info: $blue-500;
$warning: $amber-500;
$danger: $red-500;
$body-bg: $gray-900;
$body-color: $gray-100;
$text-muted: $gray-400;
$border-radius-lg: 6px;
$logo-color: $gray-200;
$link-color: $violet-400;
$link-hover-color: $violet-300;
$sidebar-nav-color: $gray-400;
$sidebar-nav-hover-color: $gray-300;
$sidebar-nav-hover-bg: $gray-800;
$sidebar-nav-icon-color: $gray-500;
$sidebar-nav-active-bg: $gray-800;
$sidebar-nav-active-color: $violet-400;
$sidebar-nav-active-icon-color: $violet-500;
$pill-link: $gray-400;
$pill-link-active: $violet-400;
$pill-link-hover: $gray-200;
$border-color: $gray-600;
$table-border-color: $gray-700;
$table-headers-color: $gray-800;
$table-hover-bg: $gray-700;
$header-border-color: $table-border-color;
$input-bg: $gray-800;
$input-color: $gray-200;
$input-border-color: $border-color;
$card-cap-bg: $gray-700;
$card-bg-secondary: $gray-800;
$card-bg: $gray-800;
$card-border-radius: $border-radius-lg;
$code-bg: #292d3e;
$modal-content-bg: $table-headers-color;
$modal-backdrop-bg: $gray-600;
$modal-footer-border-color: $input-border-color;
$modal-header-border-color: $input-border-color;
$new-entries-bg: $violet-900;
$new-code-entries-bg: $gray-600;
$control-action-icon-color: $gray-500;
$control-action-icon-hover: $violet-400;
$nav-pills-link-active-bg: $gray-800;
$dropdown-bg: $gray-700;
$dropdown-link-color: $white;
$btn-muted-color: $gray-400;
$btn-muted-bg: $gray-800;
$btn-muted-hover-color: $gray-300;
$btn-muted-hover-bg: $gray-700;
$btn-muted-active-color: $white;
$btn-muted-active-bg: $primary;
$badge-secondary-bg: $gray-300;
$badge-secondary-color: $gray-700;
$badge-success-bg: $emerald-500;
$badge-success-color: $white;
$badge-info-bg: $blue-500;
$badge-info-color: $white;
$badge-warning-bg: $amber-500;
$badge-warning-color: $white;
$badge-danger-bg: $red-500;
$badge-danger-color: $white;
$grid-breakpoints: (
xs: 0,
sm: 2px,
md: 8px,
lg: 9px,
xl: 10px,
) !default;
$container-max-widths: (
sm: 1137px,
md: 1138px,
lg: 1139px,
xl: 1140px,
) !default;
@import 'base';
.btn-primary {
color: rgb(255, 255, 255);
}
@@ -0,0 +1,103 @@
@import 'colors';
$font-family-base: Figtree, sans-serif;
$font-weight-bold: 600;
$font-size-base: 1rem;
$badge-font-size: 0.875rem;
$link-decoration: none;
$link-hover-decoration: underline;
$primary: #7746ec;
$secondary: $gray-500;
$success: $emerald-500;
$info: $blue-500;
$warning: $amber-500;
$danger: $red-500;
$body-bg: $gray-100;
$body-color: $gray-900;
$text-muted: $gray-500;
$border-radius-lg: 6px;
$btn-focus-width: 0;
$logo-color: $gray-700;
$sidebar-nav-color: $gray-600;
$sidebar-nav-hover-color: $primary;
$sidebar-nav-hover-bg: $gray-200;
$sidebar-nav-icon-color: $gray-400;
$sidebar-nav-active-bg: $gray-200;
$sidebar-nav-active-color: $primary;
$sidebar-nav-active-icon-color: $primary;
$pill-link: $gray-600;
$pill-link-active: $violet-600;
$pill-link-hover: $gray-800;
$border-color: $gray-300;
$table-headers-color: $gray-100;
$table-border-color: $gray-200;
$table-hover-bg: $gray-100;
$header-border-color: $table-border-color;
$input-bg: $white;
$input-color: $gray-800;
$input-border-color: $border-color;
$card-cap-bg: $white;
$card-bg-secondary: $gray-100;
$card-bg: $white;
$card-border-radius: $border-radius-lg;
$code-bg: #292d3e;
$new-entries-bg: $violet-50;
$new-code-entries-bg: $gray-600;
$control-action-icon-color: $gray-300;
$control-action-icon-hover: $violet-600;
$nav-pills-link-active-bg: $gray-200;
$dropdown-bg: $white;
$dropdown-link-color: $gray-700;
$btn-muted-color: $gray-600;
$btn-muted-bg: $gray-200;
$btn-muted-hover-color: $gray-900;
$btn-muted-hover-bg: $gray-300;
$btn-muted-active-color: $white;
$btn-muted-active-bg: $primary;
$badge-secondary-bg: $gray-200;
$badge-secondary-color: $gray-600;
$badge-success-bg: $emerald-100;
$badge-success-color: $emerald-600;
$badge-info-bg: $blue-100;
$badge-info-color: $blue-600;
$badge-warning-bg: $amber-100;
$badge-warning-color: $amber-600;
$badge-danger-bg: $red-100;
$badge-danger-color: $red-600;
$grid-breakpoints: (
xs: 0,
sm: 2px,
md: 8px,
lg: 9px,
xl: 10px
) !default;
$container-max-widths: (
sm: 1137px,
md: 1138px,
lg: 1139px,
xl: 1140px
) !default;
@import 'base';
@@ -0,0 +1,54 @@
.vjs-tree {
font-family: 'Monaco', 'Menlo', 'Consolas', 'Bitstream Vera Sans Mono', monospace !important;
&.is-root {
position: relative;
}
.vjs-tree-node {
display: flex;
position: relative;
&:hover {
background-color: unset;
}
.vjs-indent-unit {
&.has-line {
border-left: 1px dotted rgba(204, 204, 204, 0.28) !important;
}
}
&.has-carets {
padding-left: 15px;
}
.has-carets.has-selector,
.has-selector {
padding-left: 30px;
}
}
.vjs-indent {
display: flex;
position: relative;
}
.vjs-indent-unit {
width: 1em;
}
.vjs-tree-brackets {
cursor: pointer;
&:hover {
color: #20a0ff;
}
}
.vjs-key {
color: #c3cbd3 !important;
padding-right: 10px;
}
.vjs-value {
@extend .text-break;
}
.vjs-value-string {
color: #c3e88d !important;
}
.vjs-value-null,
.vjs-value-number,
.vjs-value-boolean,
.vjs-value-undefined {
color: #a291f5 !important;
}
}
@@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Meta Information -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="csrf-token" content="{{ csrf_token() }}">
<link rel="shortcut icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAipJREFUeNrEV8txwjAQtQ2HHCmB3JKbSQOYCoA0gD0pgFBBwpEToQAGKmDglpwgFdg5kZtNB1BBsuusZ4RY2ZZjYGd2jGWh97Q/rUwjpziPT3V4dECboDZoXZoSka5Al5vFNMqzrpkD2IFHn8B1ZAM6BCKbQgQAuAaPWQFgjoinsoipAEcTr0FrRjmyJxLLTAI5wXFXAehBGMPYcDKIIIm5kkAGOJpwAjqHRfYpbkOXvTBBypIwpT+HCvA3Cqi9Rta8EhHOHS1YCy1oWMKHmQIcGQ90wGMfLaZIoEGAoiDGOHmxhFTr5PGZJgncZYszEGC6ogX6nNn/Ay6RGDCfYveYVOFCJuAaumbPiIk1kyUNS2H6SZngyZrMWM+i/JVlXjK4QUVI3pRTpYPlaG6yeyGvm0Jef1ItiArwQBKu8G5bTMEIhKLkU3q65D+HgieE7+MCBHbygMVMOlCK+CnVDOUZ5s00ghCt2T45C+DDD2MBW/O066YFLYGvuXU5C9i6GYaLUzqr+olQtS5aIMwwtW6QfQnv7awNVanolEWgo9nABBb1cNeSmMDyigRWZkqdPrdEkDm3SRYMr7D7odwRXdIK8e7lOuAxh8W5pHtSiOhw8S4A7iX9IErlyC5b/7t+/7Ar4TKiEuyyRuJA5cQ5Wz8gEhgPNyXvfCQPVtgI+SPxAT/vSqiSEbXh70Uvp27GRSMNeJjV2Jp5V6MGpUeuUR0wAemKuwdy8ivAAJcc0R2NFxWtAAAAAElFTkSuQmCC">
<title>Horizon{{ config('horizon.name') ? ' - ' . config('horizon.name') : '' }}</title>
<!-- Style sheets-->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:300,400,500,600" rel="stylesheet" />
{{ Laravel\Horizon\Horizon::css() }}
{{ Laravel\Horizon\Horizon::js() }}
</head>
<body>
<div id="horizon" v-cloak>
<alert :message="alert.message"
:type="alert.type"
:auto-close="alert.autoClose"
:confirmation-proceed="alert.confirmationProceed"
:confirmation-cancel="alert.confirmationCancel"
v-if="alert.type"></alert>
<div class="container mb-5">
<div class="d-flex align-items-center py-4 header">
<router-link to="/" class="logo d-flex align-items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30">
<path class="fill-primary" d="M5.26176342 26.4094389C2.04147988 23.6582233 0 19.5675182 0 15c0-4.1421356 1.67893219-7.89213562 4.39339828-10.60660172C7.10786438 1.67893219 10.8578644 0 15 0c8.2842712 0 15 6.71572875 15 15 0 8.2842712-6.7157288 15-15 15-3.716753 0-7.11777662-1.3517984-9.73823658-3.5905611zM4.03811305 15.9222506C5.70084247 14.4569342 6.87195416 12.5 10 12.5c5 0 5 5 10 5 3.1280454 0 4.2991572-1.9569336 5.961887-3.4222502C25.4934253 8.43417206 20.7645408 4 15 4 8.92486775 4 4 8.92486775 4 15c0 .3105915.01287248.6181765.03811305.9222506z"/>
</svg>
<h1 class="h4 mb-0 ms-2">
<strong>Laravel</strong> Horizon{{ config('horizon.name') ? ' - ' . config('horizon.name') : '' }}
</h1>
</router-link>
<div class="ms-auto">
<scheme-toggler></scheme-toggler>
<button class="btn btn-muted ms-2" :class="{active: autoLoadsNewEntries}" v-on:click.prevent="autoLoadNewEntries" title="Auto Load New Entries">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon" fill="currentColor">
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
<div class="row mt-4">
<div class="col-2 sidebar">
<ul class="nav flex-column">
<li class="nav-item">
<router-link active-class="active" to="/dashboard" class="nav-link d-flex align-items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.25 2A2.25 2.25 0 002 4.25v2.5A2.25 2.25 0 004.25 9h2.5A2.25 2.25 0 009 6.75v-2.5A2.25 2.25 0 006.75 2h-2.5zm0 9A2.25 2.25 0 002 13.25v2.5A2.25 2.25 0 004.25 18h2.5A2.25 2.25 0 009 15.75v-2.5A2.25 2.25 0 006.75 11h-2.5zm9-9A2.25 2.25 0 0011 4.25v2.5A2.25 2.25 0 0013.25 9h2.5A2.25 2.25 0 0018 6.75v-2.5A2.25 2.25 0 0015.75 2h-2.5zm0 9A2.25 2.25 0 0011 13.25v2.5A2.25 2.25 0 0013.25 18h2.5A2.25 2.25 0 0018 15.75v-2.5A2.25 2.25 0 0015.75 11h-2.5z" clip-rule="evenodd" />
</svg>
<span>Dashboard</span>
</router-link>
</li>
<li class="nav-item">
<router-link active-class="active" to="/monitoring" class="nav-link d-flex align-items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd" />
</svg>
<span>Monitoring</span>
</router-link>
</li>
<li class="nav-item">
<router-link active-class="active" to="/metrics" class="nav-link d-flex align-items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M15.5 2A1.5 1.5 0 0014 3.5v13a1.5 1.5 0 001.5 1.5h1a1.5 1.5 0 001.5-1.5v-13A1.5 1.5 0 0016.5 2h-1zM9.5 6A1.5 1.5 0 008 7.5v9A1.5 1.5 0 009.5 18h1a1.5 1.5 0 001.5-1.5v-9A1.5 1.5 0 0010.5 6h-1zM3.5 10A1.5 1.5 0 002 11.5v5A1.5 1.5 0 003.5 18h1A1.5 1.5 0 006 16.5v-5A1.5 1.5 0 004.5 10h-1z" />
</svg>
<span>Metrics</span>
</router-link>
</li>
<li class="nav-item">
<router-link active-class="active" to="/batches" class="nav-link d-flex align-items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M2 3.75A.75.75 0 012.75 3h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 3.75zm0 4.167a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75zm0 4.166a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75zm0 4.167a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75z" clip-rule="evenodd" />
</svg>
<span>Batches</span>
</router-link>
</li>
<li class="nav-item">
<router-link active-class="active" to="/jobs/pending" class="nav-link d-flex align-items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M2 10a8 8 0 1116 0 8 8 0 01-16 0zm5-2.25A.75.75 0 017.75 7h.5a.75.75 0 01.75.75v4.5a.75.75 0 01-.75.75h-.5a.75.75 0 01-.75-.75v-4.5zm4 0a.75.75 0 01.75-.75h.5a.75.75 0 01.75.75v4.5a.75.75 0 01-.75.75h-.5a.75.75 0 01-.75-.75v-4.5z" clip-rule="evenodd" />
</svg>
<span>Pending Jobs</span>
</router-link>
</li>
<li class="nav-item">
<router-link active-class="active" to="/jobs/completed" class="nav-link d-flex align-items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
</svg>
<span>Completed Jobs</span>
</router-link>
</li>
<li class="nav-item">
<router-link active-class="active" to="/jobs/silenced" class="nav-link d-flex align-items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M4 8c0-.26.017-.517.049-.77l7.722 7.723a33.56 33.56 0 01-3.722-.01 2 2 0 003.862.15l1.134 1.134a3.5 3.5 0 01-6.53-1.409 32.91 32.91 0 01-3.257-.508.75.75 0 01-.515-1.076A11.448 11.448 0 004 8zM17.266 13.9a.756.756 0 01-.068.116L6.389 3.207A6 6 0 0116 8c.001 1.887.455 3.665 1.258 5.234a.75.75 0 01.01.666zM3.28 2.22a.75.75 0 00-1.06 1.06l14.5 14.5a.75.75 0 101.06-1.06L3.28 2.22z" />
</svg>
<span>Silenced Jobs</span>
</router-link>
</li>
<li class="nav-item">
<router-link active-class="active" to="/failed" class="nav-link d-flex align-items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
</svg>
<span>Failed Jobs</span>
</router-link>
</li>
</ul>
</div>
<div class="col-10">
@if ($isDownForMaintenance)
<div class="alert alert-warning">
This application is in "maintenance mode". Queued jobs may not be processed unless your worker is using the "force" flag.
</div>
@endif
<router-view></router-view>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,47 @@
<?php
use Illuminate\Support\Facades\Route;
Route::prefix('api')->group(function () {
// Dashboard Routes...
Route::get('/stats', 'DashboardStatsController@index')->name('horizon.stats.index');
// Workload Routes...
Route::get('/workload', 'WorkloadController@index')->name('horizon.workload.index');
// Master Supervisor Routes...
Route::get('/masters', 'MasterSupervisorController@index')->name('horizon.masters.index');
// Monitoring Routes...
Route::get('/monitoring', 'MonitoringController@index')->name('horizon.monitoring.index');
Route::post('/monitoring', 'MonitoringController@store')->name('horizon.monitoring.store');
Route::get('/monitoring/{tag}', 'MonitoringController@paginate')->name('horizon.monitoring-tag.paginate');
Route::delete('/monitoring/{tag}', 'MonitoringController@destroy')
->name('horizon.monitoring-tag.destroy')
->where('tag', '.*');
// Job Metric Routes...
Route::get('/metrics/jobs', 'JobMetricsController@index')->name('horizon.jobs-metrics.index');
Route::get('/metrics/jobs/{id}', 'JobMetricsController@show')->name('horizon.jobs-metrics.show');
// Queue Metric Routes...
Route::get('/metrics/queues', 'QueueMetricsController@index')->name('horizon.queues-metrics.index');
Route::get('/metrics/queues/{id}', 'QueueMetricsController@show')->name('horizon.queues-metrics.show');
// Batches Routes...
Route::get('/batches', 'BatchesController@index')->name('horizon.jobs-batches.index');
Route::get('/batches/{id}', 'BatchesController@show')->name('horizon.jobs-batches.show');
Route::post('/batches/retry/{id}', 'BatchesController@retry')->name('horizon.jobs-batches.retry');
// Job Routes...
Route::get('/jobs/pending', 'PendingJobsController@index')->name('horizon.pending-jobs.index');
Route::get('/jobs/completed', 'CompletedJobsController@index')->name('horizon.completed-jobs.index');
Route::get('/jobs/silenced', 'SilencedJobsController@index')->name('horizon.silenced-jobs.index');
Route::get('/jobs/failed', 'FailedJobsController@index')->name('horizon.failed-jobs.index');
Route::get('/jobs/failed/{id}', 'FailedJobsController@show')->name('horizon.failed-jobs.show');
Route::post('/jobs/retry/{id}', 'RetryController@store')->name('horizon.retry-jobs.show');
Route::get('/jobs/{id}', 'JobsController@show')->name('horizon.jobs.show');
});
// Catch-all Route...
Route::get('/{view?}', 'HomeController@index')->where('view', '(.*)')->name('horizon.index');
@@ -0,0 +1,182 @@
<?php
namespace Laravel\Horizon;
use Illuminate\Contracts\Queue\Factory as QueueFactory;
use Illuminate\Support\Collection;
use Laravel\Horizon\Contracts\MetricsRepository;
class AutoScaler
{
/**
* The queue factory implementation.
*
* @var \Illuminate\Contracts\Queue\Factory
*/
public $queue;
/**
* The metrics repository implementation.
*
* @var \Laravel\Horizon\Contracts\MetricsRepository
*/
public $metrics;
/**
* Create a new auto-scaler instance.
*
* @param \Illuminate\Contracts\Queue\Factory $queue
* @param \Laravel\Horizon\Contracts\MetricsRepository $metrics
* @return void
*/
public function __construct(QueueFactory $queue, MetricsRepository $metrics)
{
$this->queue = $queue;
$this->metrics = $metrics;
}
/**
* Balance the workers on the given supervisor.
*
* @param \Laravel\Horizon\Supervisor $supervisor
* @return void
*/
public function scale(Supervisor $supervisor)
{
$pools = $this->poolsByQueue($supervisor);
$workers = $this->numberOfWorkersPerQueue(
$supervisor, $this->timeToClearPerQueue($supervisor, $pools)
);
$workers->each(function ($workers, $queue) use ($supervisor, $pools) {
$this->scalePool($supervisor, $pools[$queue], $workers);
});
}
/**
* Get the process pools keyed by their queue name.
*
* @param \Laravel\Horizon\Supervisor $supervisor
* @return \Illuminate\Support\Collection
*/
protected function poolsByQueue(Supervisor $supervisor)
{
return $supervisor->processPools->mapWithKeys(function ($pool) {
return [$pool->queue() => $pool];
});
}
/**
* Get the times in milliseconds needed to clear the queues.
*
* @param \Laravel\Horizon\Supervisor $supervisor
* @param \Illuminate\Support\Collection $pools
* @return \Illuminate\Support\Collection
*/
protected function timeToClearPerQueue(Supervisor $supervisor, Collection $pools)
{
return $pools->mapWithKeys(function ($pool, $queue) use ($supervisor) {
$queues = collect(explode(',', $queue))->map(function ($_queue) use ($supervisor) {
$size = $this->queue->connection($supervisor->options->connection)->readyNow($_queue);
return [
'size' => $size,
'time' => ($size * $this->metrics->runtimeForQueue($_queue)),
];
});
return [$queue => [
'size' => $queues->sum('size'),
'time' => $queues->sum('time'),
]];
});
}
/**
* Get the number of workers needed per queue for proper balance.
*
* @param \Laravel\Horizon\Supervisor $supervisor
* @param \Illuminate\Support\Collection $queues
* @return \Illuminate\Support\Collection
*/
protected function numberOfWorkersPerQueue(Supervisor $supervisor, Collection $queues)
{
$timeToClearAll = $queues->sum('time');
$totalJobs = $queues->sum('size');
return $queues->mapWithKeys(function ($timeToClear, $queue) use ($supervisor, $timeToClearAll, $totalJobs) {
if (! $supervisor->options->balancing()) {
$targetProcesses = min(
$supervisor->options->maxProcesses,
max($supervisor->options->minProcesses, $timeToClear['size'])
);
return [$queue => $targetProcesses];
}
if ($timeToClearAll > 0 &&
$supervisor->options->autoScaling()) {
$numberOfProcesses = $supervisor->options->autoScaleByNumberOfJobs()
? ($timeToClear['size'] / $totalJobs)
: ($timeToClear['time'] / $timeToClearAll);
return [$queue => $numberOfProcesses *= $supervisor->options->maxProcesses];
} elseif ($timeToClearAll == 0 &&
$supervisor->options->autoScaling()) {
return [
$queue => $timeToClear['size']
? $supervisor->options->maxProcesses
: $supervisor->options->minProcesses,
];
}
return [$queue => $supervisor->options->maxProcesses / count($supervisor->processPools)];
})->sort();
}
/**
* Scale the given pool to the recommended number of workers.
*
* @param \Laravel\Horizon\Supervisor $supervisor
* @param \Laravel\Horizon\ProcessPool $pool
* @param float $workers
* @return void
*/
protected function scalePool(Supervisor $supervisor, $pool, $workers)
{
$supervisor->pruneTerminatingProcesses();
$totalProcessCount = $pool->totalProcessCount();
$desiredProcessCount = ceil($workers);
if ($desiredProcessCount > $totalProcessCount) {
$maxUpShift = min(
max(0, $supervisor->options->maxProcesses - $supervisor->totalProcessCount()),
$supervisor->options->balanceMaxShift
);
$pool->scale(
min(
$totalProcessCount + $maxUpShift,
max($supervisor->options->minProcesses, $supervisor->options->maxProcesses - (($supervisor->processPools->count() - 1) * $supervisor->options->minProcesses)),
$desiredProcessCount
)
);
} elseif ($desiredProcessCount < $totalProcessCount) {
$maxDownShift = min(
$supervisor->totalProcessCount() - $supervisor->options->minProcesses,
$supervisor->options->balanceMaxShift
);
$pool->scale(
max(
$totalProcessCount - $maxDownShift,
$supervisor->options->minProcesses,
$desiredProcessCount
)
);
}
}
}
@@ -0,0 +1,18 @@
<?php
namespace Laravel\Horizon;
use Symfony\Component\Process\Process;
class BackgroundProcess extends Process
{
/**
* Destruct the object.
*
* @return void
*/
public function __destruct()
{
//
}
}
@@ -0,0 +1,27 @@
<?php
namespace Laravel\Horizon\Connectors;
use Illuminate\Queue\Connectors\RedisConnector as BaseConnector;
use Illuminate\Support\Arr;
use Laravel\Horizon\RedisQueue;
class RedisConnector extends BaseConnector
{
/**
* Establish a queue connection.
*
* @param array $config
* @return \Laravel\Horizon\RedisQueue
*/
public function connect(array $config)
{
return new RedisQueue(
$this->redis, $config['queue'],
Arr::get($config, 'connection', $this->connection),
Arr::get($config, 'retry_after', 60),
Arr::get($config, 'block_for', null),
Arr::get($config, 'after_commit', null)
);
}
}
@@ -0,0 +1,79 @@
<?php
namespace Laravel\Horizon\Console;
use Illuminate\Console\Command;
use Illuminate\Console\ConfirmableTrait;
use Illuminate\Queue\QueueManager;
use Illuminate\Support\Arr;
use Laravel\Horizon\Contracts\JobRepository;
use Laravel\Horizon\RedisQueue;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'horizon:clear')]
class ClearCommand extends Command
{
use ConfirmableTrait;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'horizon:clear
{connection? : The name of the queue connection}
{--queue= : The name of the queue to clear}
{--force : Force the operation to run when in production}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete all of the jobs from the specified queue';
/**
* Execute the console command.
*
* @return int|null
*/
public function handle(JobRepository $jobRepository, QueueManager $manager)
{
if (! $this->confirmToProceed()) {
return 1;
}
if (! method_exists(RedisQueue::class, 'clear')) {
$this->components->error('Clearing queues is not supported on this version of Laravel.');
return 1;
}
$connection = $this->argument('connection')
?: Arr::first($this->laravel['config']->get('horizon.defaults'))['connection'] ?? 'redis';
if (method_exists($jobRepository, 'purge')) {
$jobRepository->purge($queue = $this->getQueue($connection));
}
$count = $manager->connection($connection)->clear($queue);
$this->components->info('Cleared '.$count.' jobs from the ['.$queue.'] queue.');
return 0;
}
/**
* Get the queue name to clear.
*
* @param string $connection
* @return string
*/
protected function getQueue($connection)
{
return $this->option('queue') ?: $this->laravel['config']->get(
"queue.connections.{$connection}.queue",
'default'
);
}
}
@@ -0,0 +1,38 @@
<?php
namespace Laravel\Horizon\Console;
use Illuminate\Console\Command;
use Laravel\Horizon\Contracts\MetricsRepository;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'horizon:clear-metrics')]
class ClearMetricsCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'horizon:clear-metrics';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete metrics for all jobs and queues';
/**
* Execute the console command.
*
* @param \Laravel\Horizon\Contracts\MetricsRepository $metrics
* @return void
*/
public function handle(MetricsRepository $metrics)
{
$metrics->clear();
$this->components->info('Metrics cleared successfully.');
}
}
@@ -0,0 +1,56 @@
<?php
namespace Laravel\Horizon\Console;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Laravel\Horizon\Contracts\MasterSupervisorRepository;
use Laravel\Horizon\MasterSupervisor;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'horizon:continue')]
class ContinueCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'horizon:continue';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Instruct the master supervisor to continue processing jobs';
/**
* Execute the console command.
*
* @param \Laravel\Horizon\Contracts\MasterSupervisorRepository $masters
* @return void
*/
public function handle(MasterSupervisorRepository $masters)
{
$masters = collect($masters->all())
->filter(fn ($master) => Str::startsWith($master->name, MasterSupervisor::basename()))
->all();
collect(Arr::pluck($masters, 'pid'))
->whenNotEmpty(fn () => $this->components->info('Sending CONT signal to processes.'))
->whenEmpty(fn () => $this->components->info('No processes to continue.'))
->each(function ($processId) {
$result = true;
$this->components->task("Process: $processId", function () use ($processId, &$result) {
return $result = posix_kill($processId, SIGCONT);
});
if (! $result) {
$this->components->error("Failed to kill process: {$processId} (".posix_strerror(posix_get_last_error()).')');
}
})->whenNotEmpty(fn () => $this->output->writeln(''));
}
}
@@ -0,0 +1,54 @@
<?php
namespace Laravel\Horizon\Console;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
use Laravel\Horizon\Contracts\SupervisorRepository;
use Laravel\Horizon\MasterSupervisor;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'horizon:continue-supervisor')]
class ContinueSupervisorCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'horizon:continue-supervisor
{name : The name of the supervisor to resume}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Instruct the supervisor to continue processing jobs';
/**
* Execute the console command.
*
* @param \Laravel\Horizon\Contracts\SupervisorRepository $supervisors
* @return int|void
*/
public function handle(SupervisorRepository $supervisors)
{
$processId = optional(collect($supervisors->all())->first(function ($supervisor) {
return Str::startsWith($supervisor->name, MasterSupervisor::basename())
&& Str::endsWith($supervisor->name, $this->argument('name'));
}))->pid;
if (is_null($processId)) {
$this->components->error('Failed to find a supervisor with this name');
return 1;
}
$this->components->info("Sending CONT signal to process: {$processId}");
if (! posix_kill($processId, SIGCONT)) {
$this->components->error("Failed to send CONT signal to process: {$processId} (".posix_strerror(posix_get_last_error()).')');
}
}
}
@@ -0,0 +1,71 @@
<?php
namespace Laravel\Horizon\Console;
use Illuminate\Console\Command;
use Laravel\Horizon\Contracts\JobRepository;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'horizon:forget')]
class ForgetFailedCommand extends Command
{
/**
* The console command signature.
*
* @var string
*/
protected $signature = 'horizon:forget {id? : The ID of the failed job} {--all : Delete all failed jobs}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete a failed queue job';
/**
* Execute the console command.
*
* @return int|null
*/
public function handle(JobRepository $repository)
{
if ($this->option('all')) {
$totalFailedCount = $repository->totalFailed();
do {
$failedJobs = collect($repository->getFailed());
$failedJobs->pluck('id')->each(function ($failedId) use ($repository): void {
$repository->deleteFailed($failedId);
if ($this->laravel['queue.failer']->forget($failedId)) {
$this->components->info('Failed job (id): '.$failedId.' deleted successfully!');
}
});
} while ($repository->totalFailed() !== 0 && $failedJobs->isNotEmpty());
if ($totalFailedCount) {
$this->components->info($totalFailedCount.' failed jobs deleted successfully!');
} else {
$this->components->info('No failed jobs detected.');
}
return;
}
if (! $this->argument('id')) {
$this->components->error('No failed job ID provided.');
}
$repository->deleteFailed($this->argument('id'));
if ($this->laravel['queue.failer']->forget($this->argument('id'))) {
$this->components->info('Failed job deleted successfully!');
} else {
$this->components->error('No failed job matches the given ID.');
return 1;
}
}
}
@@ -0,0 +1,62 @@
<?php
namespace Laravel\Horizon\Console;
use Illuminate\Console\Command;
use Laravel\Horizon\Contracts\MasterSupervisorRepository;
use Laravel\Horizon\MasterSupervisor;
use Laravel\Horizon\ProvisioningPlan;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'horizon')]
class HorizonCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'horizon {--environment= : The environment name}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Start a master supervisor in the foreground';
/**
* Execute the console command.
*
* @param \Laravel\Horizon\Contracts\MasterSupervisorRepository $masters
* @return void
*/
public function handle(MasterSupervisorRepository $masters)
{
if ($masters->find(MasterSupervisor::name())) {
return $this->components->warn('A master supervisor is already running on this machine.');
}
$environment = $this->option('environment') ?? config('horizon.env') ?? config('app.env');
$master = (new MasterSupervisor($environment))->handleOutputUsing(function ($type, $line) {
$this->output->write($line);
});
ProvisioningPlan::get(MasterSupervisor::name())->deploy($environment);
$this->components->info('Horizon started successfully.');
pcntl_async_signals(true);
pcntl_signal(SIGINT, function () use ($master) {
$this->output->writeln('');
$this->components->info('Shutting down.');
return $master->terminate();
});
$master->monitor();
}
}
@@ -0,0 +1,77 @@
<?php
namespace Laravel\Horizon\Console;
use Illuminate\Console\Command;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'horizon:install')]
class InstallCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'horizon:install';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Install all of the Horizon resources';
/**
* Execute the console command.
*
* @return void
*/
public function handle()
{
$this->components->info('Installing Horizon resources.');
collect([
'Service Provider' => fn () => $this->callSilent('vendor:publish', ['--tag' => 'horizon-provider']) == 0,
'Configuration' => fn () => $this->callSilent('vendor:publish', ['--tag' => 'horizon-config']) == 0,
])->each(fn ($task, $description) => $this->components->task($description, $task));
$this->registerHorizonServiceProvider();
$this->components->info('Horizon scaffolding installed successfully.');
}
/**
* Register the Horizon service provider in the application configuration file.
*
* @return void
*/
protected function registerHorizonServiceProvider()
{
$namespace = Str::replaceLast('\\', '', $this->laravel->getNamespace());
if (file_exists($this->laravel->bootstrapPath('providers.php'))) {
ServiceProvider::addProviderToBootstrapFile("{$namespace}\\Providers\\HorizonServiceProvider");
} else {
$appConfig = file_get_contents(config_path('app.php'));
if (Str::contains($appConfig, $namespace.'\\Providers\\HorizonServiceProvider::class')) {
return;
}
file_put_contents(config_path('app.php'), str_replace(
"{$namespace}\\Providers\EventServiceProvider::class,".PHP_EOL,
"{$namespace}\\Providers\EventServiceProvider::class,".PHP_EOL." {$namespace}\Providers\HorizonServiceProvider::class,".PHP_EOL,
$appConfig
));
}
file_put_contents(app_path('Providers/HorizonServiceProvider.php'), str_replace(
"namespace App\Providers;",
"namespace {$namespace}\Providers;",
file_get_contents(app_path('Providers/HorizonServiceProvider.php'))
));
}
}
@@ -0,0 +1,57 @@
<?php
namespace Laravel\Horizon\Console;
use Illuminate\Console\Command;
use Laravel\Horizon\Contracts\MasterSupervisorRepository;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'horizon:list')]
class ListCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'horizon:list';
/**
* The console command description.
*
* @var string
*/
protected $description = 'List all of the deployed machines';
/**
* Execute the console command.
*
* @param \Laravel\Horizon\Contracts\MasterSupervisorRepository $masters
* @return void
*/
public function handle(MasterSupervisorRepository $masters)
{
$masters = $masters->all();
if (empty($masters)) {
return $this->components->info('No machines are running.');
}
$this->output->writeln('');
$this->table([
'Name', 'PID', 'Supervisors', 'Status',
], collect($masters)->map(function ($master) {
return [
$master->name,
$master->pid,
$master->supervisors ? collect($master->supervisors)->map(function ($supervisor) {
return explode(':', $supervisor, 2)[1];
})->implode(', ') : 'None',
$master->status,
];
})->all());
$this->output->writeln('');
}
}
@@ -0,0 +1,56 @@
<?php
namespace Laravel\Horizon\Console;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Laravel\Horizon\Contracts\MasterSupervisorRepository;
use Laravel\Horizon\MasterSupervisor;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'horizon:pause')]
class PauseCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'horizon:pause';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Pause the master supervisor';
/**
* Execute the console command.
*
* @param \Laravel\Horizon\Contracts\MasterSupervisorRepository $masters
* @return void
*/
public function handle(MasterSupervisorRepository $masters)
{
$masters = collect($masters->all())
->filter(fn ($master) => Str::startsWith($master->name, MasterSupervisor::basename()))
->all();
collect(Arr::pluck($masters, 'pid'))
->whenNotEmpty(fn () => $this->components->info('Sending USR2 signal to processes.'))
->whenEmpty(fn () => $this->components->info('No processes to pause.'))
->each(function ($processId) {
$result = true;
$this->components->task("Process: $processId", function () use ($processId, &$result) {
return $result = posix_kill($processId, SIGUSR2);
});
if (! $result) {
$this->components->error("Failed to kill process: {$processId} (".posix_strerror(posix_get_last_error()).')');
}
})->whenNotEmpty(fn () => $this->output->writeln(''));
}
}
@@ -0,0 +1,54 @@
<?php
namespace Laravel\Horizon\Console;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
use Laravel\Horizon\Contracts\SupervisorRepository;
use Laravel\Horizon\MasterSupervisor;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'horizon:pause-supervisor')]
class PauseSupervisorCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'horizon:pause-supervisor
{name : The name of the supervisor to pause}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Pause a supervisor';
/**
* Execute the console command.
*
* @param \Laravel\Horizon\Contracts\SupervisorRepository $supervisors
* @return int|void
*/
public function handle(SupervisorRepository $supervisors)
{
$processId = optional(collect($supervisors->all())->first(function ($supervisor) {
return Str::startsWith($supervisor->name, MasterSupervisor::basename())
&& Str::endsWith($supervisor->name, $this->argument('name'));
}))->pid;
if (is_null($processId)) {
$this->components->error('Failed to find a supervisor with this name');
return 1;
}
$this->components->info("Sending USR2 signal to process: {$processId}");
if (! posix_kill($processId, SIGUSR2)) {
$this->components->error("Failed to send USR2 signal to process: {$processId} (".posix_strerror(posix_get_last_error()).')');
}
}
}
@@ -0,0 +1,34 @@
<?php
namespace Laravel\Horizon\Console;
use Illuminate\Console\Command;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'horizon:publish')]
class PublishCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'horizon:publish';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Publish all of the Horizon resources';
/**
* Execute the console command.
*
* @return void
*/
public function handle()
{
$this->components->warn('Horizon no longer publishes its assets. You may stop calling the `horizon:publish` command.');
}
}
@@ -0,0 +1,139 @@
<?php
namespace Laravel\Horizon\Console;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
use Laravel\Horizon\Contracts\MasterSupervisorRepository;
use Laravel\Horizon\Contracts\ProcessRepository;
use Laravel\Horizon\Contracts\SupervisorRepository;
use Laravel\Horizon\MasterSupervisor;
use Laravel\Horizon\ProcessInspector;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'horizon:purge')]
class PurgeCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'horizon:purge
{--signal=SIGTERM : The signal to send to the rogue processes}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Terminate any rogue Horizon processes';
/**
* @var \Laravel\Horizon\Contracts\SupervisorRepository
*/
private $supervisors;
/**
* @var \Laravel\Horizon\Contracts\ProcessRepository
*/
private $processes;
/**
* @var \Laravel\Horizon\ProcessInspector
*/
private $inspector;
/**
* Create a new command instance.
*
* @param \Laravel\Horizon\Contracts\SupervisorRepository $supervisors
* @param \Laravel\Horizon\Contracts\ProcessRepository $processes
* @param \Laravel\Horizon\ProcessInspector $inspector
* @return void
*/
public function __construct(
SupervisorRepository $supervisors,
ProcessRepository $processes,
ProcessInspector $inspector,
) {
parent::__construct();
$this->supervisors = $supervisors;
$this->processes = $processes;
$this->inspector = $inspector;
}
/**
* Execute the console command.
*
* @param \Laravel\Horizon\Contracts\MasterSupervisorRepository $masters
* @return void
*/
public function handle(MasterSupervisorRepository $masters)
{
$signal = is_numeric($signal = $this->option('signal'))
? $signal
: constant($signal);
foreach ($masters->names() as $master) {
if (Str::startsWith($master, MasterSupervisor::basename())) {
$this->purge($master, $signal);
}
}
}
/**
* Purge any orphan processes.
*
* @param string $master
* @param int $signal
* @return void
*/
public function purge($master, $signal = SIGTERM)
{
$this->recordOrphans($master, $signal);
$expired = $this->processes->orphanedFor(
$master, $this->supervisors->longestActiveTimeout()
);
collect($expired)
->whenNotEmpty(fn () => $this->components->info('Sending TERM signal to expired processes of ['.$master.']'))
->each(function ($processId) use ($master, $signal) {
$this->components->task("Process: $processId", function () use ($processId, $signal) {
exec("kill -s {$signal} {$processId}");
});
$this->processes->forgetOrphans($master, [$processId]);
})->whenNotEmpty(fn () => $this->output->writeln(''));
}
/**
* Record the orphaned Horizon processes.
*
* @param string $master
* @param int $signal
* @return void
*/
protected function recordOrphans($master, $signal)
{
$this->processes->orphaned(
$master, $orphans = $this->inspector->orphaned()
);
collect($orphans)
->whenNotEmpty(fn () => $this->components->info('Sending TERM signal to orphaned processes of ['.$master.']'))
->each(function ($processId) use ($signal) {
$result = true;
$this->components->task("Process: $processId", function () use ($processId, $signal, &$result) {
return $result = posix_kill($processId, $signal);
});
if (! $result) {
$this->components->error("Failed to kill orphan process: {$processId} (".posix_strerror(posix_get_last_error()).')');
}
})->whenNotEmpty(fn () => $this->output->writeln(''));
}
}
@@ -0,0 +1,42 @@
<?php
namespace Laravel\Horizon\Console;
use Illuminate\Console\Command;
use Laravel\Horizon\Contracts\MetricsRepository;
use Laravel\Horizon\Lock;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'horizon:snapshot')]
class SnapshotCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'horizon:snapshot';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Store a snapshot of the queue metrics';
/**
* Execute the console command.
*
* @param \Laravel\Horizon\Lock $lock
* @param \Laravel\Horizon\Contracts\MetricsRepository $metrics
* @return void
*/
public function handle(Lock $lock, MetricsRepository $metrics)
{
if ($lock->get('metrics:snapshot', config('horizon.metrics.snapshot_lock', 300) - 30)) {
$metrics->snapshot();
$this->components->info('Metrics snapshot stored successfully.');
}
}
}
@@ -0,0 +1,52 @@
<?php
namespace Laravel\Horizon\Console;
use Illuminate\Console\Command;
use Laravel\Horizon\Contracts\MasterSupervisorRepository;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'horizon:status')]
class StatusCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'horizon:status';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Get the current status of Horizon';
/**
* Execute the console command.
*
* @param \Laravel\Horizon\Contracts\MasterSupervisorRepository $masterSupervisorRepository
* @return int
*/
public function handle(MasterSupervisorRepository $masterSupervisorRepository)
{
if (! $masters = $masterSupervisorRepository->all()) {
$this->components->error('Horizon is inactive.');
return 2;
}
if (collect($masters)->contains(function ($master) {
return $master->status === 'paused';
})) {
$this->components->warn('Horizon is paused.');
return 1;
}
$this->components->info('Horizon is running.');
return 0;
}
}
@@ -0,0 +1,159 @@
<?php
namespace Laravel\Horizon\Console;
use Exception;
use Illuminate\Console\Command;
use Laravel\Horizon\SupervisorFactory;
use Laravel\Horizon\SupervisorOptions;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'horizon:supervisor')]
class SupervisorCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'horizon:supervisor
{name : The name of supervisor}
{connection : The name of the connection to work}
{--balance= : The balancing strategy the supervisor should apply}
{--delay=0 : The number of seconds to delay failed jobs (Deprecated)}
{--backoff=0 : The number of seconds to wait before retrying a job that encountered an uncaught exception}
{--max-jobs=0 : The number of jobs to process before stopping a child process}
{--max-time=0 : The maximum number of seconds a child process should run}
{--force : Force the worker to run even in maintenance mode}
{--max-processes=1 : The maximum number of total workers to start}
{--min-processes=1 : The minimum number of workers to assign per queue}
{--memory=128 : The memory limit in megabytes}
{--nice=0 : The process priority}
{--paused : Start the supervisor in a paused state}
{--queue= : The names of the queues to work}
{--sleep=3 : Number of seconds to sleep when no job is available}
{--timeout=60 : The number of seconds a child process can run}
{--tries=0 : Number of times to attempt a job before logging it failed}
{--auto-scaling-strategy=time : If supervisor should scale by jobs or time to complete}
{--balance-cooldown=3 : The number of seconds to wait in between auto-scaling attempts}
{--balance-max-shift=1 : The maximum number of processes to increase or decrease per one scaling}
{--workers-name=default : The name that should be assigned to the workers}
{--parent-id=0 : The parent process ID}
{--rest=0 : Number of seconds to rest between jobs}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Start a new supervisor';
/**
* Indicates whether the command should be shown in the Artisan command list.
*
* @var bool
*/
protected $hidden = true;
/**
* Execute the console command.
*
* @param \Laravel\Horizon\SupervisorFactory $factory
* @return int|null
*/
public function handle(SupervisorFactory $factory)
{
$supervisor = $factory->make(
$this->supervisorOptions()
);
try {
$supervisor->ensureNoDuplicateSupervisors();
} catch (Exception $e) {
$this->components->error('A supervisor with this name is already running.');
return 13;
}
$this->start($supervisor);
}
/**
* Start the given supervisor.
*
* @param \Laravel\Horizon\Supervisor $supervisor
* @return void
*/
protected function start($supervisor)
{
if ($supervisor->options->nice) {
proc_nice($supervisor->options->nice);
}
$supervisor->handleOutputUsing(function ($type, $line) {
$this->output->write($line);
});
$supervisor->working = ! $this->option('paused');
$balancedWorkerCount = floor(($this->option('min-processes') + $this->option('max-processes')) / 2);
$supervisor->scale(max(
0, $balancedWorkerCount - $supervisor->totalSystemProcessCount()
));
$supervisor->monitor();
}
/**
* Get the supervisor options.
*
* @return \Laravel\Horizon\SupervisorOptions
*/
protected function supervisorOptions()
{
$backoff = $this->hasOption('backoff')
? $this->option('backoff')
: $this->option('delay');
$balance = $this->option('balance');
$autoScalingStrategy = $balance === 'auto' ? $this->option('auto-scaling-strategy') : null;
return new SupervisorOptions(
$this->argument('name'),
$this->argument('connection'),
$this->getQueue($this->argument('connection')),
$this->option('workers-name'),
$balance,
$backoff,
$this->option('max-time'),
$this->option('max-jobs'),
$this->option('max-processes'),
$this->option('min-processes'),
$this->option('memory'),
$this->option('timeout'),
$this->option('sleep'),
$this->option('tries'),
$this->option('force'),
$this->option('nice'),
$this->option('balance-cooldown'),
$this->option('balance-max-shift'),
$this->option('parent-id'),
$this->option('rest'),
$autoScalingStrategy
);
}
/**
* Get the queue name for the worker.
*
* @param string $connection
* @return string
*/
protected function getQueue($connection)
{
return $this->option('queue') ?: $this->laravel['config']->get(
"queue.connections.{$connection}.queue", 'default'
);
}
}
@@ -0,0 +1,52 @@
<?php
namespace Laravel\Horizon\Console;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
use Laravel\Horizon\Contracts\SupervisorRepository;
use Laravel\Horizon\MasterSupervisor;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'horizon:supervisor-status')]
class SupervisorStatusCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'horizon:supervisor-status
{name : The name of the supervisor}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Show the status for a given supervisor';
/**
* Execute the console command.
*
* @param \Laravel\Horizon\Contracts\SupervisorRepository $supervisors
* @return int|void
*/
public function handle(SupervisorRepository $supervisors)
{
$name = $this->argument('name');
$supervisorStatus = optional(collect($supervisors->all())->first(function ($supervisor) use ($name) {
return Str::startsWith($supervisor->name, MasterSupervisor::basename()) &&
Str::endsWith($supervisor->name, $name);
}))->status;
if (is_null($supervisorStatus)) {
$this->components->error('Unable to find a supervisor with this name.');
return 1;
}
$this->components->info("{$name} is {$supervisorStatus}");
}
}
@@ -0,0 +1,58 @@
<?php
namespace Laravel\Horizon\Console;
use Illuminate\Console\Command;
use Laravel\Horizon\Contracts\SupervisorRepository;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'horizon:supervisors')]
class SupervisorsCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'horizon:supervisors';
/**
* The console command description.
*
* @var string
*/
protected $description = 'List all of the supervisors';
/**
* Execute the console command.
*
* @param \Laravel\Horizon\Contracts\SupervisorRepository $supervisors
* @return void
*/
public function handle(SupervisorRepository $supervisors)
{
$supervisors = $supervisors->all();
if (empty($supervisors)) {
return $this->components->info('No supervisors are running.');
}
$this->output->writeln('');
$this->table([
'Name', 'PID', 'Status', 'Workers', 'Balancing',
], collect($supervisors)->map(function ($supervisor) {
return [
$supervisor->name,
$supervisor->pid,
$supervisor->status,
collect($supervisor->processes)->map(function ($count, $queue) {
return $queue.' ('.$count.')';
})->implode(', '),
$supervisor->options['balance'],
];
})->all());
$this->output->writeln('');
}
}
@@ -0,0 +1,70 @@
<?php
namespace Laravel\Horizon\Console;
use Illuminate\Console\Command;
use Illuminate\Contracts\Cache\Factory as CacheFactory;
use Illuminate\Support\Arr;
use Illuminate\Support\InteractsWithTime;
use Illuminate\Support\Str;
use Laravel\Horizon\Contracts\MasterSupervisorRepository;
use Laravel\Horizon\MasterSupervisor;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'horizon:terminate')]
class TerminateCommand extends Command
{
use InteractsWithTime;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'horizon:terminate
{--wait : Wait for all workers to terminate}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Terminate the master supervisor so it can be restarted';
/**
* Execute the console command.
*
* @param \Illuminate\Contracts\Cache\Factory $cache
* @param \Laravel\Horizon\Contracts\MasterSupervisorRepository $masters
* @return void
*/
public function handle(CacheFactory $cache, MasterSupervisorRepository $masters)
{
if (config('horizon.fast_termination')) {
$cache->forever(
'horizon:terminate:wait', $this->option('wait')
);
}
$masters = collect($masters->all())
->filter(fn ($master) => Str::startsWith($master->name, MasterSupervisor::basename()))
->all();
collect(Arr::pluck($masters, 'pid'))
->whenNotEmpty(fn () => $this->components->info('Sending TERM signal to processes.'))
->whenEmpty(fn () => $this->components->info('No processes to terminate.'))
->each(function ($processId) {
$result = true;
$this->components->task("Process: $processId", function () use ($processId, &$result) {
return $result = posix_kill($processId, SIGTERM);
});
if (! $result) {
$this->components->error("Failed to kill process: {$processId} (".posix_strerror(posix_get_last_error()).')');
}
})->whenNotEmpty(fn () => $this->output->writeln(''));
$this->laravel['cache']->forever('illuminate:queue:restart', $this->currentTime());
}
}
@@ -0,0 +1,49 @@
<?php
namespace Laravel\Horizon\Console;
use Illuminate\Console\Command;
use Laravel\Horizon\MasterSupervisor;
use Laravel\Horizon\ProvisioningPlan;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'horizon:timeout')]
class TimeoutCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'horizon:timeout {environment=production : The environment name}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Get the maximum timeout for the given environment';
/**
* Indicates whether the command should be shown in the Artisan command list.
*
* @var bool
*/
protected $hidden = true;
/**
* Execute the console command.
*
* @return void
*/
public function handle()
{
$plan = ProvisioningPlan::get(MasterSupervisor::name())->plan;
$environment = $this->argument('environment');
$timeout = collect($plan[$this->argument('environment')] ?? [])->max('timeout') ?? 60;
$this->components->info('Maximum timeout for '.$environment.' environment: '.$timeout.' seconds.');
}
}
@@ -0,0 +1,54 @@
<?php
namespace Laravel\Horizon\Console;
use Illuminate\Queue\Console\WorkCommand as BaseWorkCommand;
class WorkCommand extends BaseWorkCommand
{
/**
* The console command name.
*
* @var string
*/
protected $signature = 'horizon:work
{connection? : The name of the queue connection to work}
{--name=default : The name of the worker}
{--delay=0 : The number of seconds to delay failed jobs (Deprecated)}
{--backoff=0 : The number of seconds to wait before retrying a job that encountered an uncaught exception}
{--max-jobs=0 : The number of jobs to process before stopping}
{--max-time=0 : The maximum number of seconds the worker should run}
{--daemon : Run the worker in daemon mode (Deprecated)}
{--force : Force the worker to run even in maintenance mode}
{--memory=128 : The memory limit in megabytes}
{--once : Only process the next job on the queue}
{--stop-when-empty : Stop when the queue is empty}
{--queue= : The names of the queues to work}
{--sleep=3 : Number of seconds to sleep when no job is available}
{--rest=0 : Number of seconds to rest between jobs}
{--supervisor= : The name of the supervisor the worker belongs to}
{--timeout=60 : The number of seconds a child process can run}
{--tries=0 : Number of times to attempt a job before logging it failed}
{--json : Output the queue worker information as JSON}';
/**
* Indicates whether the command should be shown in the Artisan command list.
*
* @var bool
*/
protected $hidden = true;
/**
* Execute the console command.
*
* @return void
*/
public function handle()
{
if (config('horizon.fast_termination')) {
ignore_user_abort(true);
}
parent::handle();
}
}
@@ -0,0 +1,32 @@
<?php
namespace Laravel\Horizon\Contracts;
interface HorizonCommandQueue
{
/**
* Push a command onto a queue.
*
* @param string $name
* @param string $command
* @param array $options
* @return void
*/
public function push($name, $command, array $options = []);
/**
* Get the pending commands for a given queue name.
*
* @param string $name
* @return array
*/
public function pending($name);
/**
* Flush the command queue for a given queue name.
*
* @param string $name
* @return void
*/
public function flush($name);
}
@@ -0,0 +1,246 @@
<?php
namespace Laravel\Horizon\Contracts;
use Illuminate\Support\Collection;
use Laravel\Horizon\JobPayload;
interface JobRepository
{
/**
* Get the next job ID that should be assigned.
*
* @return string
*/
public function nextJobId();
/**
* Get the total count of recent jobs.
*
* @return int
*/
public function totalRecent();
/**
* Get the total count of failed jobs.
*
* @return int
*/
public function totalFailed();
/**
* Get a chunk of recent jobs.
*
* @param string|null $afterIndex
* @return \Illuminate\Support\Collection
*/
public function getRecent($afterIndex = null);
/**
* Get a chunk of failed jobs.
*
* @param string|null $afterIndex
* @return \Illuminate\Support\Collection
*/
public function getFailed($afterIndex = null);
/**
* Get a chunk of pending jobs.
*
* @param string|null $afterIndex
* @return \Illuminate\Support\Collection
*/
public function getPending($afterIndex = null);
/**
* Get a chunk of completed jobs.
*
* @param string|null $afterIndex
* @return \Illuminate\Support\Collection
*/
public function getCompleted($afterIndex = null);
/**
* Get a chunk of silenced jobs.
*
* @param string|null $afterIndex
* @return \Illuminate\Support\Collection
*/
public function getSilenced($afterIndex = null);
/**
* Get the count of recent jobs.
*
* @return int
*/
public function countRecent();
/**
* Get the count of failed jobs.
*
* @return int
*/
public function countFailed();
/**
* Get the count of pending jobs.
*
* @return int
*/
public function countPending();
/**
* Get the count of completed jobs.
*
* @return int
*/
public function countCompleted();
/**
* Get the count of silenced jobs.
*
* @return int
*/
public function countSilenced();
/**
* Get the count of the recently failed jobs.
*
* @return int
*/
public function countRecentlyFailed();
/**
* Retrieve the jobs with the given IDs.
*
* @param array $ids
* @param mixed $indexFrom
* @return \Illuminate\Support\Collection
*/
public function getJobs(array $ids, $indexFrom = 0);
/**
* Insert the job into storage.
*
* @param string $connection
* @param string $queue
* @param \Laravel\Horizon\JobPayload $payload
* @return void
*/
public function pushed($connection, $queue, JobPayload $payload);
/**
* Mark the job as reserved.
*
* @param string $connection
* @param string $queue
* @param \Laravel\Horizon\JobPayload $payload
* @return void
*/
public function reserved($connection, $queue, JobPayload $payload);
/**
* Mark the job as released / pending.
*
* @param string $connection
* @param string $queue
* @param \Laravel\Horizon\JobPayload $payload
* @return void
*/
public function released($connection, $queue, JobPayload $payload);
/**
* Mark the job as completed and monitored.
*
* @param string $connection
* @param string $queue
* @param \Laravel\Horizon\JobPayload $payload
* @return void
*/
public function remember($connection, $queue, JobPayload $payload);
/**
* Mark the given jobs as released / pending.
*
* @param string $connection
* @param string $queue
* @param \Illuminate\Support\Collection $payloads
* @return void
*/
public function migrated($connection, $queue, Collection $payloads);
/**
* Handle the storage of a completed job.
*
* @param \Laravel\Horizon\JobPayload $payload
* @param bool $failed
* @param bool $silenced
* @return void
*/
public function completed(JobPayload $payload, $failed = false, $silenced = false);
/**
* Delete the given monitored jobs by IDs.
*
* @param array $ids
* @return void
*/
public function deleteMonitored(array $ids);
/**
* Trim the recent job list.
*
* @return void
*/
public function trimRecentJobs();
/**
* Trim the failed job list.
*
* @return void
*/
public function trimFailedJobs();
/**
* Trim the monitored job list.
*
* @return void
*/
public function trimMonitoredJobs();
/**
* Find a failed job by ID.
*
* @param string $id
* @return \stdClass|null
*/
public function findFailed($id);
/**
* Mark the job as failed.
*
* @param \Exception $exception
* @param string $connection
* @param string $queue
* @param \Laravel\Horizon\JobPayload $payload
* @return void
*/
public function failed($exception, $connection, $queue, JobPayload $payload);
/**
* Store the retry job ID on the original job record.
*
* @param string $id
* @param string $retryId
* @return void
*/
public function storeRetryReference($id, $retryId);
/**
* Delete a failed job by ID.
*
* @param string $id
* @return int
*/
public function deleteFailed($id);
}
@@ -0,0 +1,8 @@
<?php
namespace Laravel\Horizon\Contracts;
interface LongWaitDetectedNotification
{
//
}
@@ -0,0 +1,61 @@
<?php
namespace Laravel\Horizon\Contracts;
use Laravel\Horizon\MasterSupervisor;
interface MasterSupervisorRepository
{
/**
* Get the names of all the master supervisors currently running.
*
* @return array
*/
public function names();
/**
* Get information on all of the master supervisors.
*
* @return array
*/
public function all();
/**
* Get information on a master supervisor by name.
*
* @param string $name
* @return array
*/
public function find($name);
/**
* Get information on the given master supervisors.
*
* @param array $names
* @return array
*/
public function get(array $names);
/**
* Update the information about the given master supervisor.
*
* @param \Laravel\Horizon\MasterSupervisor $master
* @return void
*/
public function update(MasterSupervisor $master);
/**
* Remove the master supervisor information from storage.
*
* @param string $name
* @return void
*/
public function forget($name);
/**
* Remove expired master supervisors from storage.
*
* @return void
*/
public function flushExpired();
}
@@ -0,0 +1,143 @@
<?php
namespace Laravel\Horizon\Contracts;
interface MetricsRepository
{
/**
* Get all of the class names that have metrics measurements.
*
* @return array
*/
public function measuredJobs();
/**
* Get all of the queues that have metrics measurements.
*
* @return array
*/
public function measuredQueues();
/**
* Get the jobs processed per minute since the last snapshot.
*
* @return int
*/
public function jobsProcessedPerMinute();
/**
* Get the application's total throughput since the last snapshot.
*
* @return int
*/
public function throughput();
/**
* Get the throughput for a given job.
*
* @param string $job
* @return int
*/
public function throughputForJob($job);
/**
* Get the throughput for a given queue.
*
* @param string $queue
* @return int
*/
public function throughputForQueue($queue);
/**
* Get the average runtime for a given job in milliseconds.
*
* @param string $job
* @return float
*/
public function runtimeForJob($job);
/**
* Get the average runtime for a given queue in milliseconds.
*
* @param string $queue
* @return float
*/
public function runtimeForQueue($queue);
/**
* Get the queue that has the longest runtime.
*
* @return int
*/
public function queueWithMaximumRuntime();
/**
* Get the queue that has the most throughput.
*
* @return int
*/
public function queueWithMaximumThroughput();
/**
* Increment the metrics information for a job.
*
* @param string $job
* @param float|null $runtime
* @return void
*/
public function incrementJob($job, $runtime);
/**
* Increment the metrics information for a queue.
*
* @param string $queue
* @param float|null $runtime
* @return void
*/
public function incrementQueue($queue, $runtime);
/**
* Get all of the snapshots for the given job.
*
* @param string $job
* @return array
*/
public function snapshotsForJob($job);
/**
* Get all of the snapshots for the given queue.
*
* @param string $queue
* @return array
*/
public function snapshotsForQueue($queue);
/**
* Store a snapshot of the metrics information.
*
* @return void
*/
public function snapshot();
/**
* Attempt to acquire a lock to monitor the queue wait times.
*
* @return bool
*/
public function acquireWaitTimeMonitorLock();
/**
* Clear the metrics for a key.
*
* @param string $key
* @return void
*/
public function forget($key);
/**
* Delete all stored metrics information.
*
* @return void
*/
public function clear();
}
@@ -0,0 +1,20 @@
<?php
namespace Laravel\Horizon\Contracts;
interface Pausable
{
/**
* Pause the process.
*
* @return void
*/
public function pause();
/**
* Instruct the process to continue working.
*
* @return void
*/
public function continue();
}
@@ -0,0 +1,41 @@
<?php
namespace Laravel\Horizon\Contracts;
interface ProcessRepository
{
/**
* Get all of the orphan process IDs and the times they were observed.
*
* @param string $master
* @return array
*/
public function allOrphans($master);
/**
* Record the given process IDs as orphaned.
*
* @param string $master
* @param array $processIds
* @return array
*/
public function orphaned($master, array $processIds);
/**
* Get the process IDs orphaned for at least the given number of seconds.
*
* @param string $master
* @param int $seconds
* @return array
*/
public function orphanedFor($master, $seconds);
/**
* Remove the given process IDs from the orphan list.
*
* @param string $master
* @param array $processIds
* @return void
*/
public function forgetOrphans($master, array $processIds);
}
@@ -0,0 +1,13 @@
<?php
namespace Laravel\Horizon\Contracts;
interface Restartable
{
/**
* Restart the process.
*
* @return void
*/
public function restart();
}
@@ -0,0 +1,7 @@
<?php
namespace Laravel\Horizon\Contracts;
interface Silenced
{
}
@@ -0,0 +1,68 @@
<?php
namespace Laravel\Horizon\Contracts;
use Laravel\Horizon\Supervisor;
interface SupervisorRepository
{
/**
* Get the names of all the supervisors currently running.
*
* @return array
*/
public function names();
/**
* Get information on all of the supervisors.
*
* @return array
*/
public function all();
/**
* Get information on a supervisor by name.
*
* @param string $name
* @return array
*/
public function find($name);
/**
* Get information on the given supervisors.
*
* @param array $names
* @return array
*/
public function get(array $names);
/**
* Get the longest active timeout setting for a supervisor.
*
* @return int
*/
public function longestActiveTimeout();
/**
* Update the information about the given supervisor process.
*
* @param \Laravel\Horizon\Supervisor $supervisor
* @return void
*/
public function update(Supervisor $supervisor);
/**
* Remove the supervisor information from storage.
*
* @param array|string $names
* @return void
*/
public function forget($names);
/**
* Remove expired supervisors from storage.
*
* @return void
*/
public function flushExpired();
}
@@ -0,0 +1,90 @@
<?php
namespace Laravel\Horizon\Contracts;
interface TagRepository
{
/**
* Get the currently monitored tags.
*
* @return array
*/
public function monitoring();
/**
* Return the tags which are being monitored.
*
* @param array $tags
* @return array
*/
public function monitored(array $tags);
/**
* Start monitoring the given tag.
*
* @param string $tag
* @return void
*/
public function monitor($tag);
/**
* Stop monitoring the given tag.
*
* @param string $tag
* @return void
*/
public function stopMonitoring($tag);
/**
* Store the tags for the given job.
*
* @param string $id
* @param array $tags
* @return void
*/
public function add($id, array $tags);
/**
* Store the tags for the given job temporarily.
*
* @param int $minutes
* @param string $id
* @param array $tags
* @return void
*/
public function addTemporary($minutes, $id, array $tags);
/**
* Get the number of jobs matching a given tag.
*
* @param string $tag
* @return int
*/
public function count($tag);
/**
* Get all of the job IDs for a given tag.
*
* @param string $tag
* @return array
*/
public function jobs($tag);
/**
* Paginate the job IDs for a given tag.
*
* @param string $tag
* @param int $startingAt
* @param int $limit
* @return array
*/
public function paginate($tag, $startingAt = 0, $limit = 25);
/**
* Delete the given tag from storage.
*
* @param string $tag
* @return void
*/
public function forget($tag);
}
@@ -0,0 +1,14 @@
<?php
namespace Laravel\Horizon\Contracts;
interface Terminable
{
/**
* Terminate the process.
*
* @param int $status
* @return void
*/
public function terminate($status = 0);
}
@@ -0,0 +1,13 @@
<?php
namespace Laravel\Horizon\Contracts;
interface WorkloadRepository
{
/**
* Get the current workload of each queue.
*
* @return array<int, array{"name": string, "length": int, "wait": int, "processes": int, "split_queues": null|array<int, array{"name": string, "wait": int, "length": int}>}>
*/
public function get();
}
@@ -0,0 +1,76 @@
<?php
namespace Laravel\Horizon;
trait EventMap
{
/**
* All of the Horizon event / listener mappings.
*
* @var array
*/
protected $events = [
Events\JobPushed::class => [
Listeners\StoreJob::class,
Listeners\StoreMonitoredTags::class,
],
Events\JobReserved::class => [
Listeners\MarkJobAsReserved::class,
Listeners\StartTimingJob::class,
],
Events\JobReleased::class => [
Listeners\MarkJobAsReleased::class,
],
Events\JobDeleted::class => [
Listeners\MarkJobAsComplete::class,
Listeners\UpdateJobMetrics::class,
],
Events\JobsMigrated::class => [
Listeners\MarkJobsAsMigrated::class,
],
\Illuminate\Queue\Events\JobExceptionOccurred::class => [
Listeners\ForgetJobTimer::class,
],
\Illuminate\Queue\Events\JobFailed::class => [
Listeners\ForgetJobTimer::class,
Listeners\MarshalFailedEvent::class,
],
Events\JobFailed::class => [
Listeners\MarkJobAsFailed::class,
Listeners\StoreTagsForFailedJob::class,
],
Events\MasterSupervisorLooped::class => [
Listeners\TrimRecentJobs::class,
Listeners\TrimFailedJobs::class,
Listeners\TrimMonitoredJobs::class,
Listeners\ExpireSupervisors::class,
Listeners\MonitorMasterSupervisorMemory::class,
],
Events\SupervisorLooped::class => [
Listeners\PruneTerminatingProcesses::class,
Listeners\MonitorSupervisorMemory::class,
Listeners\MonitorWaitTimes::class,
],
Events\WorkerProcessRestarting::class => [
//
],
Events\SupervisorProcessRestarting::class => [
//
],
Events\LongWaitDetected::class => [
Listeners\SendNotification::class,
],
];
}
@@ -0,0 +1,27 @@
<?php
namespace Laravel\Horizon\Events;
class JobDeleted extends RedisEvent
{
/**
* The queue job instance.
*
* @var \Illuminate\Queue\Jobs\Job
*/
public $job;
/**
* Create a new event instance.
*
* @param \Illuminate\Queue\Jobs\Job $job
* @param string $payload
* @return void
*/
public function __construct($job, $payload)
{
$this->job = $job;
parent::__construct($payload);
}
}
@@ -0,0 +1,36 @@
<?php
namespace Laravel\Horizon\Events;
class JobFailed extends RedisEvent
{
/**
* The exception that caused the failure.
*
* @var \Exception
*/
public $exception;
/**
* The queue job instance.
*
* @var \Illuminate\Queue\Jobs\Job
*/
public $job;
/**
* Create a new event instance.
*
* @param \Exception $exception
* @param \Illuminate\Queue\Jobs\Job $job
* @param string $payload
* @return void
*/
public function __construct($exception, $job, $payload)
{
$this->job = $job;
$this->exception = $exception;
parent::__construct($payload);
}
}
@@ -0,0 +1,8 @@
<?php
namespace Laravel\Horizon\Events;
class JobPushed extends RedisEvent
{
//
}
@@ -0,0 +1,8 @@
<?php
namespace Laravel\Horizon\Events;
class JobReleased extends RedisEvent
{
//
}
@@ -0,0 +1,8 @@
<?php
namespace Laravel\Horizon\Events;
class JobReserved extends RedisEvent
{
//
}
@@ -0,0 +1,68 @@
<?php
namespace Laravel\Horizon\Events;
use Laravel\Horizon\JobPayload;
class JobsMigrated
{
/**
* The connection name.
*
* @var string
*/
public $connectionName;
/**
* The queue name.
*
* @var string
*/
public $queue;
/**
* The job payloads that were migrated.
*
* @var \Illuminate\Support\Collection
*/
public $payloads;
/**
* Create a new event instance.
*
* @param array $payloads
* @return void
*/
public function __construct($payloads)
{
$this->payloads = collect($payloads)->map(function ($job) {
return new JobPayload($job);
});
}
/**
* Set the connection name.
*
* @param string $connectionName
* @return $this
*/
public function connection($connectionName)
{
$this->connectionName = $connectionName;
return $this;
}
/**
* Set the queue name.
*
* @param string $queue
* @return $this
*/
public function queue($queue)
{
$this->queue = $queue;
return $this;
}
}
@@ -0,0 +1,59 @@
<?php
namespace Laravel\Horizon\Events;
use Illuminate\Container\Container;
use Laravel\Horizon\Contracts\LongWaitDetectedNotification;
class LongWaitDetected
{
/**
* The queue connection name.
*
* @var string
*/
public $connection;
/**
* The queue name.
*
* @var string
*/
public $queue;
/**
* The wait time in seconds.
*
* @var int
*/
public $seconds;
/**
* Create a new event instance.
*
* @param string $connection
* @param string $queue
* @param int $seconds
* @return void
*/
public function __construct($connection, $queue, $seconds)
{
$this->queue = $queue;
$this->seconds = $seconds;
$this->connection = $connection;
}
/**
* Get a notification representation of the event.
*
* @return \Laravel\Horizon\Notifications\LongWaitDetected
*/
public function toNotification()
{
return Container::getInstance()->make(LongWaitDetectedNotification::class, [
'connection' => $this->connection,
'queue' => $this->queue,
'seconds' => $this->seconds,
]);
}
}
@@ -0,0 +1,24 @@
<?php
namespace Laravel\Horizon\Events;
class MasterSupervisorDeployed
{
/**
* The master supervisor that was deployed.
*
* @var string
*/
public $master;
/**
* Create a new event instance.
*
* @param string $master
* @return void
*/
public function __construct($master)
{
$this->master = $master;
}
}
@@ -0,0 +1,26 @@
<?php
namespace Laravel\Horizon\Events;
use Laravel\Horizon\MasterSupervisor;
class MasterSupervisorLooped
{
/**
* The master supervisor instance.
*
* @var \Laravel\Horizon\MasterSupervisor
*/
public $master;
/**
* Create a new event instance.
*
* @param \Laravel\Horizon\MasterSupervisor $master
* @return void
*/
public function __construct(MasterSupervisor $master)
{
$this->master = $master;
}
}
@@ -0,0 +1,26 @@
<?php
namespace Laravel\Horizon\Events;
use Laravel\Horizon\MasterSupervisor;
class MasterSupervisorOutOfMemory
{
/**
* The master supervisor instance.
*
* @var \Laravel\Horizon\MasterSupervisor
*/
public $master;
/**
* Create a new event instance.
*
* @param \Laravel\Horizon\MasterSupervisor $master
* @return void
*/
public function __construct(MasterSupervisor $master)
{
$this->master = $master;
}
}
@@ -0,0 +1,24 @@
<?php
namespace Laravel\Horizon\Events;
class MasterSupervisorReviving
{
/**
* The master supervisor that was dead.
*
* @var string
*/
public $master;
/**
* Create a new event instance.
*
* @param string $master
* @return void
*/
public function __construct($master)
{
$this->master = $master;
}
}
@@ -0,0 +1,66 @@
<?php
namespace Laravel\Horizon\Events;
use Laravel\Horizon\JobPayload;
class RedisEvent
{
/**
* The connection name.
*
* @var string
*/
public $connectionName;
/**
* The queue name.
*
* @var string
*/
public $queue;
/**
* The job payload.
*
* @var JobPayload
*/
public $payload;
/**
* Create a new event instance.
*
* @param string $payload
* @return void
*/
public function __construct($payload)
{
$this->payload = new JobPayload($payload);
}
/**
* Set the connection name.
*
* @param string $connectionName
* @return $this
*/
public function connection($connectionName)
{
$this->connectionName = $connectionName;
return $this;
}
/**
* Set the queue name.
*
* @param string $queue
* @return $this
*/
public function queue($queue)
{
$this->queue = $queue;
return $this;
}
}
@@ -0,0 +1,26 @@
<?php
namespace Laravel\Horizon\Events;
use Laravel\Horizon\Supervisor;
class SupervisorLooped
{
/**
* The supervisor instance.
*
* @var \Laravel\Horizon\Supervisor
*/
public $supervisor;
/**
* Create a new event instance.
*
* @param \Laravel\Horizon\Supervisor $supervisor
* @return void
*/
public function __construct(Supervisor $supervisor)
{
$this->supervisor = $supervisor;
}
}
@@ -0,0 +1,56 @@
<?php
namespace Laravel\Horizon\Events;
use Laravel\Horizon\Supervisor;
class SupervisorOutOfMemory
{
/**
* The supervisor instance.
*
* @var \Laravel\Horizon\Supervisor
*/
public $supervisor;
/**
* The memory usage that exceeded the allowable limit.
*
* @var int|float
*/
public $memoryUsage;
/**
* Create a new event instance.
*
* @param \Laravel\Horizon\Supervisor $supervisor
* @return void
*/
public function __construct(Supervisor $supervisor)
{
$this->supervisor = $supervisor;
}
/**
* Get the memory usage that triggered the event.
*
* @return int|float
*/
public function getMemoryUsage()
{
return $this->memoryUsage ?? $this->supervisor->memoryUsage();
}
/**
* Set the memory usage that was recorded when the event was dispatched.
*
* @param int|float $memoryUsage
* @return $this
*/
public function setMemoryUsage($memoryUsage)
{
$this->memoryUsage = $memoryUsage;
return $this;
}
}
@@ -0,0 +1,26 @@
<?php
namespace Laravel\Horizon\Events;
use Laravel\Horizon\SupervisorProcess;
class SupervisorProcessRestarting
{
/**
* The supervisor process instance.
*
* @var \Laravel\Horizon\SupervisorProcess
*/
public $process;
/**
* Create a new event instance.
*
* @param \Laravel\Horizon\SupervisorProcess $process
* @return void
*/
public function __construct(SupervisorProcess $process)
{
$this->process = $process;
}
}
@@ -0,0 +1,26 @@
<?php
namespace Laravel\Horizon\Events;
use Laravel\Horizon\WorkerProcess;
class UnableToLaunchProcess
{
/**
* The worker process instance.
*
* @var \Laravel\Horizon\WorkerProcess
*/
public $process;
/**
* Create a new event instance.
*
* @param \Laravel\Horizon\WorkerProcess $process
* @return void
*/
public function __construct(WorkerProcess $process)
{
$this->process = $process;
}
}
@@ -0,0 +1,26 @@
<?php
namespace Laravel\Horizon\Events;
use Laravel\Horizon\WorkerProcess;
class WorkerProcessRestarting
{
/**
* The worker process instance.
*
* @var \Laravel\Horizon\WorkerProcess
*/
public $process;
/**
* Create a new event instance.
*
* @param \Laravel\Horizon\WorkerProcess $process
* @return void
*/
public function __construct(WorkerProcess $process)
{
$this->process = $process;
}
}
@@ -0,0 +1,18 @@
<?php
namespace Laravel\Horizon\Exceptions;
use Symfony\Component\HttpKernel\Exception\HttpException;
class ForbiddenException extends HttpException
{
/**
* Create a new exception instance.
*
* @return static
*/
public static function make()
{
return new static(403);
}
}
@@ -0,0 +1,19 @@
<?php
namespace Laravel\Horizon;
class Exec
{
/**
* Run the given command.
*
* @param string $command
* @return array
*/
public function run($command)
{
exec($command, $output);
return $output;
}
}
@@ -0,0 +1,228 @@
<?php
namespace Laravel\Horizon;
use Closure;
use Exception;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Js;
use RuntimeException;
class Horizon
{
/**
* The callback that should be used to authenticate Horizon users.
*
* @var \Closure
*/
public static $authUsing;
/**
* The Slack notifications webhook URL.
*
* @var string
*/
public static $slackWebhookUrl;
/**
* The Slack notifications channel.
*
* @var string
*/
public static $slackChannel;
/**
* The SMS notifications phone number.
*
* @var string
*/
public static $smsNumber;
/**
* The email address for notifications.
*
* @var string
*/
public static $email;
/**
* Indicates if Horizon should use the dark theme.
*
* @deprecated
*
* @var bool
*/
public static $useDarkTheme = false;
/**
* The database configuration methods.
*
* @var array
*/
public static $databases = [
'Jobs', 'Supervisors', 'CommandQueue', 'Tags',
'Metrics', 'Locks', 'Processes',
];
/**
* Determine if the given request can access the Horizon dashboard.
*
* @param \Illuminate\Http\Request $request
* @return bool
*/
public static function check($request)
{
return (static::$authUsing ?: function () {
return app()->environment('local');
})($request);
}
/**
* Set the callback that should be used to authenticate Horizon users.
*
* @param \Closure $callback
* @return static
*/
public static function auth(Closure $callback)
{
static::$authUsing = $callback;
return new static;
}
/**
* Configure the Redis databases that will store Horizon data.
*
* @param string $connection
* @return void
*
* @throws \Exception
*/
public static function use($connection)
{
if (! is_null($config = config("database.redis.clusters.{$connection}.0"))) {
config(["database.redis.{$connection}" => $config]);
} elseif (is_null($config) && is_null($config = config("database.redis.{$connection}"))) {
throw new Exception("Redis connection [{$connection}] has not been configured.");
}
$config['options']['prefix'] = config('horizon.prefix') ?: 'horizon:';
config(['database.redis.horizon' => $config]);
}
/**
* Get the CSS for the Horizon dashboard.
*
* @return Illuminate\Contracts\Support\Htmlable
*/
public static function css()
{
if (($light = @file_get_contents(__DIR__.'/../dist/styles.css')) === false) {
throw new RuntimeException('Unable to load the Horizon dashboard light CSS.');
}
if (($dark = @file_get_contents(__DIR__.'/../dist/styles-dark.css')) === false) {
throw new RuntimeException('Unable to load the Horizon dashboard dark CSS.');
}
if (($app = @file_get_contents(__DIR__.'/../dist/app.css')) === false) {
throw new RuntimeException('Unable to load the Horizon dashboard CSS.');
}
return new HtmlString(<<<HTML
<style data-scheme="light">{$light}</style>
<style data-scheme="dark">{$dark}</style>
<style>{$app}</style>
HTML);
}
/**
* Get the JS for the Horizon dashboard.
*
* @return \Illuminate\Contracts\Support\Htmlable
*/
public static function js()
{
if (($js = @file_get_contents(__DIR__.'/../dist/app.js')) === false) {
throw new RuntimeException('Unable to load the Horizon dashboard JavaScript.');
}
$horizon = Js::from(static::scriptVariables());
return new HtmlString(<<<HTML
<script type="module">
window.Horizon = {$horizon};
{$js}
</script>
HTML);
}
/**
* Specifies that Horizon should use the dark theme.
*
* @deprecated
*
* @return static
*/
public static function night()
{
static::$useDarkTheme = true;
return new static;
}
/**
* Get the default JavaScript variables for Horizon.
*
* @return array
*/
public static function scriptVariables()
{
return [
'path' => config('horizon.path'),
'proxy_path' => config('horizon.proxy_path', ''),
];
}
/**
* Specify the email address to which email notifications should be routed.
*
* @param string $email
* @return static
*/
public static function routeMailNotificationsTo($email)
{
static::$email = $email;
return new static;
}
/**
* Specify the webhook URL and channel to which Slack notifications should be routed.
*
* @param string $url
* @param string|null $channel
* @return static
*/
public static function routeSlackNotificationsTo($url, $channel = null)
{
static::$slackWebhookUrl = $url;
static::$slackChannel = $channel;
return new static;
}
/**
* Specify the phone number to which SMS notifications should be routed.
*
* @param string $number
* @return static
*/
public static function routeSmsNotificationsTo($number)
{
static::$smsNumber = $number;
return new static;
}
}
@@ -0,0 +1,59 @@
<?php
namespace Laravel\Horizon;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
class HorizonApplicationServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
$this->authorization();
}
/**
* Configure the Horizon authorization services.
*
* @return void
*/
protected function authorization()
{
$this->gate();
Horizon::auth(function ($request) {
return Gate::check('viewHorizon', [$request->user()]) || app()->environment('local');
});
}
/**
* Register the Horizon gate.
*
* This gate determines who can access Horizon in non-local environments.
*
* @return void
*/
protected function gate()
{
Gate::define('viewHorizon', function ($user) {
return in_array($user->email, [
//
]);
});
}
/**
* Register any application services.
*
* @return void
*/
public function register()
{
//
}
}
@@ -0,0 +1,205 @@
<?php
namespace Laravel\Horizon;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\Foundation\CachesRoutes;
use Illuminate\Queue\QueueManager;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Laravel\Horizon\Connectors\RedisConnector;
class HorizonServiceProvider extends ServiceProvider
{
use EventMap, ServiceBindings;
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
$this->normalizeConfig();
$this->registerEvents();
$this->registerRoutes();
$this->registerResources();
$this->offerPublishing();
$this->registerCommands();
}
/**
* Normalize the Horizon configuration.
*
* @return void
*/
protected function normalizeConfig()
{
if (! $this->app['config']->get('horizon.name')) {
$this->app['config']->set('horizon.name', $this->app['config']->get('app.name'));
}
}
/**
* Register the Horizon job events.
*
* @return void
*/
protected function registerEvents()
{
$events = $this->app->make(Dispatcher::class);
foreach ($this->events as $event => $listeners) {
foreach ($listeners as $listener) {
$events->listen($event, $listener);
}
}
}
/**
* Register the Horizon routes.
*
* @return void
*/
protected function registerRoutes()
{
if ($this->app instanceof CachesRoutes && $this->app->routesAreCached()) {
return;
}
Route::group([
'domain' => config('horizon.domain', null),
'prefix' => config('horizon.path'),
'namespace' => 'Laravel\Horizon\Http\Controllers',
'middleware' => config('horizon.middleware', 'web'),
], function () {
$this->loadRoutesFrom(__DIR__.'/../routes/web.php');
});
}
/**
* Register the Horizon resources.
*
* @return void
*/
protected function registerResources()
{
$this->loadViewsFrom(__DIR__.'/../resources/views', 'horizon');
}
/**
* Setup the resource publishing groups for Horizon.
*
* @return void
*/
protected function offerPublishing()
{
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__.'/../stubs/HorizonServiceProvider.stub' => app_path('Providers/HorizonServiceProvider.php'),
], 'horizon-provider');
$this->publishes([
__DIR__.'/../config/horizon.php' => config_path('horizon.php'),
], 'horizon-config');
}
}
/**
* Register the Horizon Artisan commands.
*
* @return void
*/
protected function registerCommands()
{
if ($this->app->runningInConsole()) {
$this->commands([
Console\ClearCommand::class,
Console\ClearMetricsCommand::class,
Console\ContinueCommand::class,
Console\ContinueSupervisorCommand::class,
Console\ForgetFailedCommand::class,
Console\HorizonCommand::class,
Console\InstallCommand::class,
Console\ListCommand::class,
Console\PauseCommand::class,
Console\PauseSupervisorCommand::class,
Console\PublishCommand::class,
Console\PurgeCommand::class,
Console\SupervisorCommand::class,
Console\SupervisorStatusCommand::class,
Console\TerminateCommand::class,
Console\TimeoutCommand::class,
Console\WorkCommand::class,
]);
}
$this->commands([
Console\SnapshotCommand::class,
Console\StatusCommand::class,
Console\SupervisorsCommand::class,
]);
}
/**
* Register any application services.
*
* @return void
*/
public function register()
{
if (! defined('HORIZON_PATH')) {
define('HORIZON_PATH', realpath(__DIR__.'/../'));
}
$this->app->bind(Console\WorkCommand::class, function ($app) {
return new Console\WorkCommand($app['queue.worker'], $app['cache.store']);
});
$this->configure();
$this->registerServices();
$this->registerQueueConnectors();
}
/**
* Setup the configuration for Horizon.
*
* @return void
*/
protected function configure()
{
$this->mergeConfigFrom(
__DIR__.'/../config/horizon.php', 'horizon'
);
Horizon::use(config('horizon.use', 'default'));
}
/**
* Register Horizon's services in the container.
*
* @return void
*/
protected function registerServices()
{
foreach ($this->serviceBindings as $key => $value) {
is_numeric($key)
? $this->app->singleton($value)
: $this->app->singleton($key, $value);
}
}
/**
* Register the custom queue connectors for Horizon.
*
* @return void
*/
protected function registerQueueConnectors()
{
$this->callAfterResolving(QueueManager::class, function ($manager) {
$manager->addConnector('redis', function () {
return new RedisConnector($this->app['redis']);
});
});
}
}

Some files were not shown because too many files have changed in this diff Show More