├── LICENSE ├── Makefile ├── README.md ├── UPGRADE.md ├── composer.json ├── config ├── admin_routing.yaml ├── config.yaml ├── grids.yaml ├── resources.yaml ├── services.yaml ├── twig_hooks.yaml └── validation.yaml ├── doc └── images │ ├── Commands.png │ └── ScheduledCommands.png ├── install └── Application │ └── config │ ├── packages │ ├── sylius_fixtures.yaml │ ├── sylius_scheduler_command.yaml │ └── test │ │ └── sylius_scheduler_command.yaml │ └── routes │ └── sylius_scheduler_command.yaml ├── migrations ├── Version20200107151826.php ├── Version20210810054537.php ├── Version20210828114655.php └── Version20211224090032.php ├── node_modules ├── public ├── column │ └── index.js └── controller │ ├── index.css │ └── index.js ├── src ├── Action │ └── CleanLogAction.php ├── Checker │ ├── EveryMinuteIsDueChecker.php │ ├── IsDueCheckerInterface.php │ └── SoftLimitThresholdIsDueChecker.php ├── Command │ ├── PurgeScheduledCommandCommand.php │ └── SynoliaSchedulerRunCommand.php ├── Components │ └── Exceptions │ │ └── Checker │ │ └── IsNotDueException.php ├── Controller │ ├── DownloadController.php │ ├── EmptyLogsController.php │ ├── LogViewerController.php │ └── ScheduledCommandExecuteImmediateController.php ├── DataRetriever │ └── LogDataRetriever.php ├── DependencyInjection │ └── SynoliaSyliusSchedulerCommandExtension.php ├── DoctrineEvent │ └── ScheduledCommandPostRemoveEvent.php ├── Entity │ ├── Command.php │ ├── CommandInterface.php │ ├── ScheduledCommand.php │ └── ScheduledCommandInterface.php ├── Enum │ └── ScheduledCommandStateEnum.php ├── EventSubscriber │ └── ConsoleSubscriber.php ├── Fixture │ └── SchedulerCommandFixture.php ├── Form │ ├── CommandChoiceType.php │ ├── CommandType.php │ └── ScheduledCommandType.php ├── Grid │ └── FieldType │ │ ├── DatetimeFieldType.php │ │ ├── ScheduledCommandExecutionTimeType.php │ │ ├── ScheduledCommandHumanReadableExpressionType.php │ │ ├── ScheduledCommandStateType.php │ │ └── ScheduledCommandUrlType.php ├── Humanizer │ ├── CronExpressionHumanizer.php │ └── HumanizerInterface.php ├── Listener │ └── Grid │ │ ├── GoToCommandsButtonGridListener.php │ │ └── GoToHistoryButtonGridListener.php ├── Menu │ └── AdminMenuListener.php ├── Parser │ ├── CommandParser.php │ └── CommandParserInterface.php ├── Planner │ ├── ScheduledCommandPlanner.php │ └── ScheduledCommandPlannerInterface.php ├── Provider │ └── CalendarWithTimezone.php ├── Repository │ ├── CommandRepository.php │ ├── CommandRepositoryInterface.php │ ├── ScheduledCommandRepository.php │ └── ScheduledCommandRepositoryInterface.php ├── Runner │ ├── ScheduleCommandRunner.php │ └── ScheduleCommandRunnerInterface.php ├── SynoliaSyliusSchedulerCommandPlugin.php ├── Twig │ └── BytesFormatterExtension.php ├── Validator │ ├── LogfilePrefixPropertyValidator.php │ └── LogfilePropertyValidator.php └── Voter │ ├── IsDueVoter.php │ └── IsDueVoterInterface.php ├── templates ├── Controller │ ├── css.html.twig │ ├── js.html.twig │ ├── modal.html.twig │ └── show.html.twig └── Grid │ ├── Action │ ├── empty_log_file.html.twig │ ├── empty_logs_file.html.twig │ ├── execute_immediate.html.twig │ └── link.html.twig │ └── Column │ ├── human_readable_expression.html.twig │ ├── js.html.twig │ ├── log_file.html.twig │ └── scheduled_command_state.html.twig └── translations ├── messages.en.yml ├── messages.fr.yml ├── messages.nl.yml ├── validators.en.yaml └── validators.fr.yaml /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Synolia 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | SHELL=/bin/bash 3 | COMPOSER_ROOT=composer 4 | TEST_DIRECTORY=tests/Application 5 | INSTALL_DIRECTORY=install/Application 6 | CONSOLE=cd ${TEST_DIRECTORY} && php bin/console -e test 7 | COMPOSER=cd ${TEST_DIRECTORY} && composer 8 | YARN=cd ${TEST_DIRECTORY} && yarn 9 | 10 | SYLIUS_VERSION=2.0 11 | SYMFONY_VERSION=7.2 12 | PHP_VERSION=8.2 13 | PLUGIN_NAME=synolia/sylius-scheduler-command-plugin 14 | 15 | ### 16 | ### DEVELOPMENT 17 | ### ¯¯¯¯¯¯¯¯¯¯¯ 18 | 19 | install: sylius ## Install Plugin on Sylius [SYLIUS_VERSION=2.0] [SYMFONY_VERSION=7.2] [PHP_VERSION=8.2] 20 | .PHONY: install 21 | 22 | reset: ## Remove dependencies 23 | ifneq ("$(wildcard ${TEST_DIRECTORY}/bin/console)","") 24 | ${CONSOLE} doctrine:database:drop --force --if-exists || true 25 | endif 26 | rm -rf ${TEST_DIRECTORY} 27 | .PHONY: reset 28 | 29 | phpunit: phpunit-configure phpunit-run ## Run PHPUnit 30 | .PHONY: phpunit 31 | 32 | ### 33 | ### OTHER 34 | ### ¯¯¯¯¯¯ 35 | 36 | sylius: sylius-standard update-dependencies install-plugin install-sylius 37 | .PHONY: sylius 38 | 39 | sylius-standard: 40 | ifeq ($(shell [[ $(SYLIUS_VERSION) == *dev ]] && echo true ),true) 41 | ${COMPOSER_ROOT} create-project sylius/sylius-standard:${SYLIUS_VERSION} ${TEST_DIRECTORY} --no-install --no-scripts 42 | else 43 | ${COMPOSER_ROOT} create-project sylius/sylius-standard ${TEST_DIRECTORY} "~${SYLIUS_VERSION}" --no-install --no-scripts 44 | endif 45 | ${COMPOSER} config allow-plugins true 46 | ifeq ($(shell [[ $(SYLIUS_VERSION) == *dev ]] && echo true ),true) 47 | ${COMPOSER} require --no-update sylius/sylius:"${SYLIUS_VERSION}" 48 | else 49 | ${COMPOSER} require --no-update sylius/sylius:"~${SYLIUS_VERSION}" 50 | endif 51 | 52 | update-dependencies: 53 | ${COMPOSER} config extra.symfony.require "~${SYMFONY_VERSION}" 54 | ${COMPOSER} require symfony/asset:~${SYMFONY_VERSION} --no-scripts --no-update 55 | ${COMPOSER} update --no-progress -n 56 | 57 | install-plugin: 58 | ${COMPOSER} config repositories.plugin '{"type": "path", "url": "../../"}' 59 | ${COMPOSER} config extra.symfony.allow-contrib true 60 | ${COMPOSER} config minimum-stability "dev" 61 | ${COMPOSER} config prefer-stable true 62 | ${COMPOSER} req ${PLUGIN_NAME}:* --prefer-source --no-scripts 63 | cp -r ${INSTALL_DIRECTORY} tests 64 | 65 | install-sylius: 66 | ${CONSOLE} doctrine:database:create -n --if-not-exists 67 | ${CONSOLE} doctrine:migrations:migrate -n 68 | ${CONSOLE} messenger:setup-transports -n 69 | ${CONSOLE} sylius:fixtures:load default -n 70 | ${YARN} install 71 | ${YARN} build 72 | ${CONSOLE} cache:clear 73 | 74 | phpunit-configure: 75 | cp phpunit.xml.dist ${TEST_DIRECTORY}/phpunit.xml 76 | 77 | phpunit-run: 78 | cd ${TEST_DIRECTORY} && ./vendor/bin/phpunit --testdox 79 | 80 | behat-configure: ## Configure Behat 81 | (cd ${TEST_DIRECTORY} && cp behat.yml.dist behat.yml) 82 | (cd ${TEST_DIRECTORY} && sed -i "s#vendor/sylius/sylius/src/Sylius/Behat/Resources/config/suites.yml#vendor/${PLUGIN_NAME}/tests/Behat/Resources/suites.yml#g" behat.yml) 83 | (cd ${TEST_DIRECTORY} && sed -i "s#vendor/sylius/sylius/features#vendor/${PLUGIN_NAME}/features#g" behat.yml) 84 | (cd ${TEST_DIRECTORY} && sed -i "s#@cli#@javascript#g" behat.yml) 85 | (cd ${TEST_DIRECTORY} && sed -i '2i \ \ \ \ - { resource: "../vendor/${PLUGIN_NAME}/tests/Behat/Resources/services.yml\" }' config/services_test.yaml) 86 | ${CONSOLE} cache:clear 87 | 88 | grumphp: ## Run GrumPHP 89 | vendor/bin/grumphp run 90 | 91 | help: SHELL=/bin/bash 92 | help: ## Display this help 93 | @IFS=$$'\n'; for line in `grep -h -E '^[a-zA-Z_#-]+:?.*?##.*$$' $(MAKEFILE_LIST)`; do if [ "$${line:0:2}" = "##" ]; then \ 94 | echo $$line | awk 'BEGIN {FS = "## "}; {printf "\033[33m %s\033[0m\n", $$2}'; else \ 95 | echo $$line | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m%s\n", $$1, $$2}'; fi; \ 96 | done; unset IFS; 97 | .PHONY: help 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![License](https://img.shields.io/packagist/l/synolia/sylius-scheduler-command-plugin.svg)](https://github.com/synolia/SyliusSchedulerCommandPlugin/blob/main/LICENSE) 2 | [![CI - Analysis](https://github.com/synolia/SyliusSchedulerCommandPlugin/actions/workflows/analysis.yaml/badge.svg?branch=main)](https://github.com/synolia/SyliusSchedulerCommandPlugin/actions/workflows/analysis.yaml) 3 | [![CI - Sylius](https://github.com/synolia/SyliusSchedulerCommandPlugin/actions/workflows/sylius.yaml/badge.svg?branch=main)](https://github.com/synolia/SyliusSchedulerCommandPlugin/actions/workflows/sylius.yaml) 4 | [![Version](https://img.shields.io/packagist/v/synolia/sylius-scheduler-command-plugin.svg)](https://packagist.org/packages/synolia/sylius-scheduler-command-plugin) 5 | [![Total Downloads](https://poser.pugx.org/synolia/sylius-scheduler-command-plugin/downloads)](https://packagist.org/packages/synolia/sylius-scheduler-command-plugin) 6 | 7 |

8 | 9 | 10 | 11 | 12 | Sylius Logo. 13 | 14 | 15 |

16 | 17 |

Scheduler Command Plugin

18 |

19 | 20 |

21 |

Schedule Symfony Commands in your Sylius admin panel.

22 | 23 | ## Commands list 24 | ![Commands](/doc/images/Commands.png "Commands") 25 | 26 | ## Scheduled Commands list 27 | ![Scheduled Commands](/doc/images/ScheduledCommands.png "Scheduled Commands") 28 | 29 | ## Features 30 | 31 | * See the list of planned command 32 | * Add, edit, enable/disable or delete scheduled commands 33 | * For each command, you have to define : 34 | * Name 35 | * Selected Command from the list of Symfony commands 36 | * Based on Cron schedule expression see [Cron formats](https://abunchofutils.com/u/computing/cron-format-helper/) 37 | * Output Log file prefix (optional) 38 | * Priority (highest is priority) 39 | * Run the Command immediately (at the next passage of the command `synolia:scheduler-run`) 40 | * Run a Command juste one time (from history page clic on `Launch a command` button) 41 | * Download or live view of log files directly from the admin panel 42 | * Define commands with a Factory (from a Doctrine migration, for example) 43 | 44 | ## Requirements 45 | 46 | | | Version | 47 | |:-------|:--------| 48 | | PHP | ^8.2 | 49 | | Sylius | ^1.12 | 50 | 51 | ## Installation 52 | 53 | 1. Add the bundle and dependencies in your composer.json : 54 | 55 | composer config extra.symfony.allow-contrib true 56 | composer req synolia/sylius-scheduler-command-plugin 57 | 58 | 2. Apply migrations to your database: 59 | 60 | bin/console doctrine:migrations:migrate 61 | 62 | 3. Launch Run command in your Crontab 63 | 64 | * * * * * /_PROJECT_DIRECTORY_/bin/console synolia:scheduler-run 65 | 66 | 4. (optional) Showing humanized cron expression 67 | 68 | composer require lorisleiva/cron-translator 69 | 70 | 5. Till `symfony/recipes-contrib` is updated for the v3, you must add `sylius_scheduler_command.yaml` from `install/Application/config/{packages,routes}` to your project by respecting the same folder architecture. 71 | 72 | cp -R vendor/synolia/sylius-scheduler-command-plugin/install/Application/config/packages/* config/packages/ 73 | cp -R vendor/synolia/sylius-scheduler-command-plugin/install/Application/config/routes/* config/routes/ 74 | 75 | ## Usage 76 | 77 | * Log into admin panel 78 | * Click on `Scheduled commands` in the Scheduled commands section in main menu to manage your Scheduled commands 79 | * Click on `Scheduled commands history` in the Scheduled commands section in main menu to see history of commands 80 | 81 | ## Fixtures 82 | Inside sylius fixture file `config/packages/sylius_fixtures.yaml` you can add scheduled command fixtures to your suite. 83 | ```yaml 84 | sylius_fixtures: 85 | suites: 86 | my_fixture_suite: 87 | fixtures: 88 | scheduler_command: 89 | options: 90 | scheduled_commands: 91 | - 92 | name: 'Reset Sylius' 93 | command: 'sylius:fixtures:load' 94 | cronExpression: '0 0 * * *' 95 | logFilePrefix: 'reset' 96 | priority: 0 97 | enabled: true 98 | - 99 | name: 'Cancel Unpaid Orders' 100 | command: 'sylius:cancel-unpaid-orders' 101 | cronExpression: '0 0 * * *' 102 | priority: 1 103 | enabled: false 104 | ``` 105 | 106 | ## Commands 107 | ### synolia:scheduler-run 108 | 109 | Execute scheduled commands. 110 | 111 | * options: 112 | * --id (run only a specific scheduled command) 113 | 114 | **Run all scheduled commands :** php bin/console synolia:scheduler-run 115 | 116 | **Run one scheduled command :** php bin/console synolia:scheduler-run --only-one 117 | 118 | **Run a specific scheduled command :** php bin/console synolia:scheduler-run --id=5 119 | 120 | Is it possible to choose the timezone of the command execution by setting the `SYNOLIA_SCHEDULER_PLUGIN_TIMEZONE` environment variable, example: 121 | 122 | ``` 123 | SYNOLIA_SCHEDULER_PLUGIN_TIMEZONE=Europe/Paris 124 | ``` 125 | 126 | ### synolia:scheduler:purge-history 127 | 128 | Purge scheduled command history greater than {X} days old. 129 | 130 | * options: 131 | * --all (purge everything) 132 | * --days (number of days to keep) 133 | * --state (array of schedule states) 134 | * --dry-run 135 | 136 | **Example to remove all finished and in error scheduled commands after 7 days :** 137 | 138 | php bin/console synolia:scheduler:purge-history --state=finished --state=error --days=7 139 | 140 | ## Optional services 141 | ```yaml 142 | services: 143 | ... 144 | # By enabling this service, it will be requested to vote after the other EveryMinuteIsDueChecker checker. 145 | # Using some cloud providers, even if the master cron is set to run every minute, it is actually not run that often 146 | # This service allows you to set a soft threshold limit, so if your provider is actually running the master cron every 5 minutes 147 | # This service will execute the cron if we are still in the threshold limit ONLY IF it was not already executed another time in the same range. 148 | # 149 | # CONFIGURATION SCENARIO: cron set to be run at 01:07 in the scheduler command plugin 150 | # 151 | # SCENARIO CASES AT 1 CRON PASS EVERY 5 MINUTES FROM THE PROVIDER 152 | # cron passes at 01:04 - 1..5 minutes: IS NOT DUE 153 | # cron passes at 01:05 - 1..5 minutes: IS NOT DUE 154 | # cron passes at 01:06 - 1..5 minutes: IS NOT DUE 155 | # cron passes at 01:07 - 1..5 minutes: IS DUE (but it should already be handled by EveryMinuteIsDueChecker) 156 | # cron passes at 01:08 - 1..5 minutes: IS DUE 157 | # cron passes at 01:09 - 1..5 minutes: IS DUE #should not if another has started during the threshold period 158 | # cron passes at 01:10 - 1..5 minutes: IS DUE #should not if another has started during the threshold period 159 | # cron passes at 01:11 - 1..5 minutes: IS DUE #should not if another has started during the threshold period 160 | # cron passes at 01:12 - 1..5 minutes: IS DUE #should not if another has started during the threshold period 161 | # cron passes at 01:13 - 1..5 minutes: IS NOT DUE 162 | Synolia\SyliusSchedulerCommandPlugin\Checker\SoftLimitThresholdIsDueChecker: 163 | tags: 164 | - { name: !php/const Synolia\SyliusSchedulerCommandPlugin\Checker\IsDueCheckerInterface::TAG_ID } 165 | #optionnal, default value is 5 minutes 166 | arguments: 167 | $threshold: 5 #soft limit threshold in minutes 168 | ``` 169 | 170 | ## Development 171 | 172 | See [How to contribute](CONTRIBUTING.md). 173 | 174 | ## License 175 | 176 | This library is under the MIT license. 177 | 178 | ## Credits 179 | 180 | Developed by [Synolia](https://synolia.com/). 181 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | # Upgrade 2 | 3 | ## from ^2.0 to ^3.0 4 | 5 | ### Fixtures 6 | * `logFile` field must be renamed to `logFilePrefix` and must not end with the file extension 7 | 8 | Also, for old existing schedules in your database, please remove the log file extension in column `logFilePrefix`. 9 | 10 | ## from 3.8 to 3.9 11 | 12 | * The constructors of `Synolia\SyliusSchedulerCommandPlugin\Checker\EveryMinuteIsDueChecker` and `Synolia\SyliusSchedulerCommandPlugin\Checker\SoftLimitThresholdIsDueChecker` has been modified, a new argument has been added : 13 | 14 | ```php 15 | public function __construct( 16 | // ... 17 | private ?DateTimeProviderInterface $dateTimeProvider = null, 18 | ) 19 | ``` 20 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "synolia/sylius-scheduler-command-plugin", 3 | "type": "sylius-plugin", 4 | "keywords": [ 5 | "sylius", 6 | "sylius-plugin", 7 | "scheduler", 8 | "command" 9 | ], 10 | "description": "Scheduler Command Plugin.", 11 | "license": "MIT", 12 | "require": { 13 | "php": "^8.2", 14 | "ext-json": "*", 15 | "ext-intl": "*", 16 | "dragonmantank/cron-expression": "^3.4", 17 | "sylius/sylius": "^2.0", 18 | "symfony/framework-bundle": "^5.4|^6.0|^7.0", 19 | "symfony/lock": "^6.4|^7.0", 20 | "symfony/polyfill-intl-icu": "^1.26", 21 | "symfony/process": "^6.4|^7.0", 22 | "symfony/service-contracts": "^3.5", 23 | "webmozart/assert": "^1.11" 24 | }, 25 | "require-dev": { 26 | "behat/behat": "^3.12", 27 | "behat/mink-selenium2-driver": "^1.6", 28 | "dmore/behat-chrome-extension": "^1.4", 29 | "dmore/chrome-mink-driver": "^2.9", 30 | "friends-of-behat/mink": "^1.10", 31 | "friends-of-behat/mink-browserkit-driver": "^1.6", 32 | "friends-of-behat/mink-debug-extension": "^2.1", 33 | "friends-of-behat/mink-extension": "^2.7", 34 | "friends-of-behat/page-object-extension": "^0.3", 35 | "friends-of-behat/suite-settings-extension": "^1.1", 36 | "friends-of-behat/symfony-extension": "^2.4", 37 | "friends-of-behat/variadic-extension": "^1.5", 38 | "friendsoftwig/twigcs": "6.4.0", 39 | "j13k/yaml-lint": "^1.1", 40 | "php-parallel-lint/php-parallel-lint": "^1.4", 41 | "phpmd/phpmd": "^2.15.0", 42 | "phpro/grumphp": "^2.9", 43 | "phpspec/phpspec": "^7.3", 44 | "phpstan/extension-installer": "^1.3", 45 | "phpstan/phpstan": "^2.0", 46 | "phpstan/phpstan-doctrine": "^2.0", 47 | "phpstan/phpstan-strict-rules": "^2.0", 48 | "phpstan/phpstan-webmozart-assert": "^2.0", 49 | "phpunit/phpunit": "^9.5", 50 | "rector/rector": "^2.0", 51 | "seld/jsonlint": "^1.11", 52 | "slevomat/coding-standard": "^8.7", 53 | "squizlabs/php_codesniffer": "^3.11", 54 | "sylius-labs/coding-standard": "^4.3", 55 | "symfony/browser-kit": "^6.4", 56 | "symfony/debug-bundle": "^6.4", 57 | "symfony/dotenv": "^6.4", 58 | "symfony/intl": "^6.4", 59 | "symfony/web-profiler-bundle": "^6.4", 60 | "symplify/easy-coding-standard": "^12.5" 61 | }, 62 | "suggest": { 63 | "lorisleiva/cron-translator": "Allow showing humanized and translated cron expression." 64 | }, 65 | "prefer-stable": true, 66 | "autoload": { 67 | "psr-4": { 68 | "Synolia\\SyliusSchedulerCommandPlugin\\": "src/", 69 | "Tests\\Synolia\\SyliusSchedulerCommandPlugin\\": "tests/" 70 | } 71 | }, 72 | "config": { 73 | "sort-packages": true, 74 | "allow-plugins": { 75 | "dealerdirect/phpcodesniffer-composer-installer": true, 76 | "ergebnis/composer-normalize": true, 77 | "php-http/discovery": true, 78 | "phpro/grumphp": true, 79 | "phpstan/extension-installer": true, 80 | "symfony/thanks": true 81 | } 82 | }, 83 | "scripts": { 84 | "fix-ecs": "ecs check -c ruleset/ecs.php --fix --ansi --clear-cache" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /config/admin_routing.yaml: -------------------------------------------------------------------------------- 1 | synolia_admin_commands: 2 | resource: | 3 | alias: synolia.command 4 | templates: "@SyliusAdmin\\shared\\crud" 5 | section: admin 6 | grid: synolia_admin_commands 7 | except: ['show'] 8 | permission: true 9 | type: sylius.resource 10 | 11 | synolia_admin_scheduled_commands: 12 | resource: | 13 | alias: synolia.scheduled_command 14 | templates: "@SyliusAdmin\\shared\\crud" 15 | section: admin 16 | grid: synolia_admin_scheduled_commands 17 | except: ['update', 'show'] 18 | permission: true 19 | type: sylius.resource 20 | 21 | controllers: 22 | resource: 23 | path: ../src/Controller/ 24 | namespace: Synolia\SyliusSchedulerCommandPlugin\Controller 25 | type: attribute -------------------------------------------------------------------------------- /config/config.yaml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: "resources.yaml" } 3 | - { resource: "grids.yaml" } 4 | - { resource: "services.yaml" } 5 | - { resource: "twig_hooks.yaml" } 6 | -------------------------------------------------------------------------------- /config/grids.yaml: -------------------------------------------------------------------------------- 1 | sylius_grid: 2 | grids: 3 | synolia_admin_commands: 4 | driver: 5 | options: 6 | class: Synolia\SyliusSchedulerCommandPlugin\Entity\Command 7 | fields: 8 | name: 9 | type: string 10 | sortable: ~ 11 | label: synolia.ui.scheduled_command.name 12 | command: 13 | type: string 14 | sortable: ~ 15 | label: synolia.ui.scheduled_command.command 16 | cronExpression: 17 | type: scheduled_human_readable_expression 18 | label: synolia.ui.scheduled_command.cron_expression 19 | options: 20 | template: '@SynoliaSyliusSchedulerCommandPlugin/Grid/Column/human_readable_expression.html.twig' 21 | priority: 22 | type: string 23 | sortable: ~ 24 | label: synolia.ui.scheduled_command.priority 25 | enabled: 26 | type: twig 27 | sortable: ~ 28 | label: synolia.ui.scheduled_command.enabled 29 | options: 30 | template: "@SyliusUi/grid/field/enabled.html.twig" 31 | sorting: 32 | name: asc 33 | command: asc 34 | priority: asc 35 | enabled: asc 36 | filters: 37 | name: 38 | label: synolia.ui.scheduled_command.name 39 | type: string 40 | enabled: true 41 | command: 42 | label: synolia.ui.scheduled_command.command 43 | type: string 44 | enabled: true 45 | cronExpression: 46 | label: synolia.ui.scheduled_command.cron_expression 47 | type: string 48 | enabled: true 49 | priority: 50 | label: synolia.ui.scheduled_command.priority 51 | type: string 52 | enabled: true 53 | enabled: 54 | label: synolia.ui.scheduled_command.enabled 55 | type: boolean 56 | enabled: true 57 | actions: 58 | main: 59 | create: 60 | type: create 61 | item: 62 | execute: 63 | type: schedule_command_execute_immediate 64 | label: synolia.ui.scheduled_command.execute_immediate 65 | options: 66 | link: 67 | route: execute_immediate_schedule 68 | parameters: 69 | commandId: resource.id 70 | update: 71 | type: update 72 | delete: 73 | type: delete 74 | bulk: 75 | delete: 76 | type: delete 77 | synolia_admin_scheduled_commands: 78 | driver: 79 | options: 80 | class: Synolia\SyliusSchedulerCommandPlugin\Entity\ScheduledCommand 81 | fields: 82 | name: 83 | type: string 84 | sortable: ~ 85 | label: synolia.ui.scheduled_command.name 86 | state: 87 | type: scheduled_command_state 88 | sortable: ~ 89 | label: synolia.ui.scheduled_command.state 90 | options: 91 | template: '@SynoliaSyliusSchedulerCommandPlugin/Grid/Column/scheduled_command_state.html.twig' 92 | executedAt: 93 | type: scheduled_command_executed_at 94 | sortable: createdAt 95 | label: synolia.ui.scheduled_command.last_execution 96 | options: 97 | date_format: !php/const \IntlDateFormatter::SHORT 98 | time_format: !php/const \IntlDateFormatter::SHORT 99 | commandExecutionTime: 100 | type: scheduled_command_execution_time 101 | label: synolia.ui.scheduled_command.command_execution_time 102 | logFile: 103 | type: scheduled_command_url 104 | sortable: ~ 105 | label: synolia.ui.scheduled_command.log_file 106 | options: 107 | template: '@SynoliaSyliusSchedulerCommandPlugin/Grid/Column/log_file.html.twig' 108 | sorting: 109 | executedAt: desc 110 | filters: 111 | id: 112 | label: synolia.ui.scheduled_command.id 113 | type: string 114 | enabled: false 115 | name: 116 | label: synolia.ui.scheduled_command.name 117 | type: string 118 | enabled: true 119 | command: 120 | label: synolia.ui.scheduled_command.command 121 | type: string 122 | enabled: true 123 | state: 124 | label: synolia.ui.scheduled_command.state 125 | type: string 126 | enabled: true 127 | actions: 128 | main: 129 | create: 130 | type: create 131 | label: synolia.ui.launch_a_command 132 | item: 133 | delete: 134 | type: delete 135 | bulk: 136 | delete: 137 | type: delete 138 | templates: 139 | action: 140 | schedule_command_execute_immediate: "@SynoliaSyliusSchedulerCommandPlugin/Grid/Action/execute_immediate.html.twig" 141 | link: "@SynoliaSyliusSchedulerCommandPlugin/Grid/Action/link.html.twig" 142 | -------------------------------------------------------------------------------- /config/resources.yaml: -------------------------------------------------------------------------------- 1 | sylius_resource: 2 | resources: 3 | synolia.command: 4 | driver: doctrine/orm 5 | classes: 6 | model: Synolia\SyliusSchedulerCommandPlugin\Entity\Command 7 | form: Synolia\SyliusSchedulerCommandPlugin\Form\CommandType 8 | repository: Synolia\SyliusSchedulerCommandPlugin\Repository\CommandRepository 9 | synolia.scheduled_command: 10 | driver: doctrine/orm 11 | classes: 12 | model: Synolia\SyliusSchedulerCommandPlugin\Entity\ScheduledCommand 13 | form: Synolia\SyliusSchedulerCommandPlugin\Form\ScheduledCommandType 14 | repository: Synolia\SyliusSchedulerCommandPlugin\Repository\ScheduledCommandRepository 15 | -------------------------------------------------------------------------------- /config/services.yaml: -------------------------------------------------------------------------------- 1 | parameters: 2 | env(SYNOLIA_SCHEDULER_PLUGIN_PING_INTERVAL): 300 3 | env(SYNOLIA_SCHEDULER_PLUGIN_KEEP_ALIVE): true 4 | env(SYNOLIA_SCHEDULER_PLUGIN_LOGS_DIR): '%kernel.logs_dir%' 5 | env(SYNOLIA_SCHEDULER_PLUGIN_TIMEFORMAT_24H): false 6 | env(SYNOLIA_SCHEDULER_PLUGIN_TIMEZONE): ~ 7 | 8 | services: 9 | _defaults: 10 | autowire: true 11 | autoconfigure: true 12 | 13 | Synolia\SyliusSchedulerCommandPlugin\: 14 | resource: '../src/*' 15 | exclude: '../src/{Entity,Migrations,SynoliaSyliusSchedulerCommandPlugin.php}' -------------------------------------------------------------------------------- /config/twig_hooks.yaml: -------------------------------------------------------------------------------- 1 | sylius_twig_hooks: 2 | hooks: 3 | 'synolia.admin.command.logs.javascripts': 4 | js: 5 | priority: 0 6 | template: '@SynoliaSyliusSchedulerCommandPlugin/Controller/js.html.twig' 7 | 'synolia.admin.command.logs.stylesheets': 8 | css: 9 | priority: 0 10 | template: '@SynoliaSyliusSchedulerCommandPlugin/Controller/css.html.twig' 11 | 'sylius_admin.scheduled_command.index#javascripts': 12 | js: 13 | priority: 0 14 | template: '@SynoliaSyliusSchedulerCommandPlugin/Grid/Column/js.html.twig' 15 | 'sylius_admin.scheduled_command.index': 16 | modal: 17 | priority: 0 18 | template: '@SynoliaSyliusSchedulerCommandPlugin/Controller/modal.html.twig' -------------------------------------------------------------------------------- /config/validation.yaml: -------------------------------------------------------------------------------- 1 | Synolia\SyliusSchedulerCommandPlugin\Entity\Command: 2 | properties: 3 | name: 4 | - NotBlank: ~ 5 | - Length: 6 | min: 2 7 | command: 8 | - NotBlank: ~ 9 | cronExpression: 10 | - NotBlank: ~ 11 | - Length: 12 | min: 9 13 | priority: 14 | - NotBlank: ~ 15 | - PositiveOrZero: ~ 16 | logFilePrefix: 17 | - Callback: [ Synolia\SyliusSchedulerCommandPlugin\Validator\LogfilePrefixPropertyValidator, validate ] 18 | 19 | Synolia\SyliusSchedulerCommandPlugin\Entity\ScheduledCommand: 20 | properties: 21 | name: 22 | - NotNull: ~ 23 | - NotBlank: ~ 24 | - Length: 25 | min: 2 26 | command: 27 | - NotBlank: ~ 28 | logFile: 29 | - Callback: [ Synolia\SyliusSchedulerCommandPlugin\Validator\LogfilePropertyValidator, validate ] 30 | 31 | -------------------------------------------------------------------------------- /doc/images/Commands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synolia/SyliusSchedulerCommandPlugin/f565c8df166738fff2000cecf3c6c5e4bba491c5/doc/images/Commands.png -------------------------------------------------------------------------------- /doc/images/ScheduledCommands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synolia/SyliusSchedulerCommandPlugin/f565c8df166738fff2000cecf3c6c5e4bba491c5/doc/images/ScheduledCommands.png -------------------------------------------------------------------------------- /install/Application/config/packages/sylius_fixtures.yaml: -------------------------------------------------------------------------------- 1 | sylius_fixtures: 2 | suites: 3 | default: 4 | listeners: 5 | orm_purger: ~ 6 | logger: ~ 7 | fixtures: 8 | scheduler_command: 9 | options: 10 | scheduled_commands: 11 | - 12 | name: 'Reset Sylius' 13 | command: 'sylius:fixtures:load' 14 | cronExpression: '0 0 * * *' 15 | logFilePrefix: 'reset' 16 | priority: 0 17 | enabled: false 18 | -------------------------------------------------------------------------------- /install/Application/config/packages/sylius_scheduler_command.yaml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: "@SynoliaSyliusSchedulerCommandPlugin/config/config.yaml" } 3 | -------------------------------------------------------------------------------- /install/Application/config/packages/test/sylius_scheduler_command.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | _defaults: 3 | autowire: true 4 | autoconfigure: true 5 | public: true 6 | 7 | Synolia\SyliusSchedulerCommandPlugin\Checker\SoftLimitThresholdIsDueChecker: ~ 8 | -------------------------------------------------------------------------------- /install/Application/config/routes/sylius_scheduler_command.yaml: -------------------------------------------------------------------------------- 1 | synolia_scheduled_command: 2 | resource: "@SynoliaSyliusSchedulerCommandPlugin/config/admin_routing.yaml" 3 | prefix: '/%sylius_admin.path_name%' 4 | -------------------------------------------------------------------------------- /migrations/Version20200107151826.php: -------------------------------------------------------------------------------- 1 | abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); 24 | 25 | $this->addSql('CREATE TABLE scheduled_command ( 26 | id INT AUTO_INCREMENT NOT NULL, 27 | name VARCHAR(255) NOT NULL, 28 | command VARCHAR(255) NOT NULL, 29 | arguments VARCHAR(255) DEFAULT NULL, 30 | cronExpression VARCHAR(255) NOT NULL, 31 | lastExecution DATETIME DEFAULT NULL, 32 | lastReturnCode INT DEFAULT NULL, 33 | logFile VARCHAR(255) DEFAULT NULL, 34 | priority INT NOT NULL, 35 | executeImmediately TINYINT(1) NOT NULL, 36 | enabled TINYINT(1) NOT NULL, 37 | commandEndTime DATETIME DEFAULT NULL, 38 | PRIMARY KEY(id) 39 | ) DEFAULT CHARACTER SET UTF8 COLLATE `UTF8_unicode_ci` ENGINE = InnoDB'); 40 | } 41 | 42 | public function down(Schema $schema): void 43 | { 44 | // this down() migration is auto-generated, please modify it to your needs 45 | $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); 46 | 47 | $this->addSql('DROP TABLE scheduled_command'); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /migrations/Version20210810054537.php: -------------------------------------------------------------------------------- 1 | addSql('CREATE TABLE synolia_commands ( 24 | id INT AUTO_INCREMENT NOT NULL, 25 | name VARCHAR(255) NOT NULL, 26 | command VARCHAR(255) NOT NULL, 27 | arguments VARCHAR(255) DEFAULT NULL, 28 | cronExpression VARCHAR(255) NOT NULL, 29 | logFilePrefix VARCHAR(255) DEFAULT NULL, 30 | priority INT NOT NULL, 31 | executeImmediately TINYINT(1) NOT NULL, 32 | enabled TINYINT(1) NOT NULL, 33 | PRIMARY KEY(id) 34 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 35 | $this->addSql('CREATE TABLE synolia_scheduled_commands ( 36 | id INT AUTO_INCREMENT NOT NULL, 37 | name VARCHAR(255) NOT NULL, 38 | command VARCHAR(255) NOT NULL, 39 | state VARCHAR(255) NOT NULL, 40 | arguments VARCHAR(255) DEFAULT NULL, 41 | executed_at DATETIME DEFAULT NULL, 42 | lastReturnCode INT DEFAULT NULL, 43 | logFile VARCHAR(255) DEFAULT NULL, 44 | commandEndTime DATETIME DEFAULT NULL, 45 | created_At DATETIME NOT NULL, 46 | PRIMARY KEY(id) 47 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 48 | $this->addSql('DROP TABLE scheduled_command'); 49 | } 50 | 51 | public function down(Schema $schema): void 52 | { 53 | // this down() migration is auto-generated, please modify it to your needs 54 | $this->addSql('CREATE TABLE scheduled_command ( 55 | id INT AUTO_INCREMENT NOT NULL, 56 | name VARCHAR(255) CHARACTER SET utf8 NOT NULL COLLATE `utf8_unicode_ci`, 57 | command VARCHAR(255) CHARACTER SET utf8 NOT NULL COLLATE `utf8_unicode_ci`, 58 | arguments VARCHAR(255) CHARACTER SET utf8 DEFAULT NULL COLLATE `utf8_unicode_ci`, 59 | cronExpression VARCHAR(255) CHARACTER SET utf8 NOT NULL COLLATE `utf8_unicode_ci`, 60 | lastExecution DATETIME DEFAULT NULL, 61 | lastReturnCode INT DEFAULT NULL, 62 | logFile VARCHAR(255) CHARACTER SET utf8 DEFAULT NULL COLLATE `utf8_unicode_ci`, 63 | priority INT NOT NULL, 64 | executeImmediately TINYINT(1) NOT NULL, 65 | enabled TINYINT(1) NOT NULL, 66 | commandEndTime DATETIME DEFAULT NULL, 67 | PRIMARY KEY(id) 68 | ) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB COMMENT = \'\''); 69 | $this->addSql('DROP TABLE synolia_commands'); 70 | $this->addSql('DROP TABLE synolia_scheduled_commands'); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /migrations/Version20210828114655.php: -------------------------------------------------------------------------------- 1 | addSql('ALTER TABLE synolia_scheduled_commands ADD owner_id INT DEFAULT NULL'); 24 | $this->addSql('ALTER TABLE 25 | synolia_scheduled_commands 26 | ADD 27 | CONSTRAINT FK_813781F7E3C61F9 FOREIGN KEY (owner_id) REFERENCES synolia_commands (id) ON DELETE 28 | SET 29 | NULL'); 30 | $this->addSql('CREATE INDEX IDX_813781F7E3C61F9 ON synolia_scheduled_commands (owner_id)'); 31 | } 32 | 33 | public function down(Schema $schema): void 34 | { 35 | // this down() migration is auto-generated, please modify it to your needs 36 | $this->addSql('ALTER TABLE synolia_scheduled_commands DROP FOREIGN KEY FK_813781F7E3C61F9'); 37 | $this->addSql('DROP INDEX IDX_813781F7E3C61F9 ON synolia_scheduled_commands'); 38 | $this->addSql('ALTER TABLE synolia_scheduled_commands DROP owner_id'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /migrations/Version20211224090032.php: -------------------------------------------------------------------------------- 1 | addSql('ALTER TABLE synolia_commands ADD timeout INT DEFAULT NULL'); 24 | $this->addSql('ALTER TABLE synolia_scheduled_commands ADD timeout INT DEFAULT NULL'); 25 | $this->addSql('ALTER TABLE synolia_commands ADD idleTimeout INT DEFAULT NULL'); 26 | $this->addSql('ALTER TABLE synolia_scheduled_commands ADD idleTimeout INT DEFAULT NULL'); 27 | } 28 | 29 | public function down(Schema $schema): void 30 | { 31 | // this down() migration is auto-generated, please modify it to your needs 32 | $this->addSql('ALTER TABLE synolia_commands DROP idleTimeout'); 33 | $this->addSql('ALTER TABLE synolia_scheduled_commands DROP idleTimeout'); 34 | $this->addSql('ALTER TABLE synolia_commands DROP timeout'); 35 | $this->addSql('ALTER TABLE synolia_scheduled_commands DROP timeout'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /node_modules: -------------------------------------------------------------------------------- 1 | tests/Application/node_modules -------------------------------------------------------------------------------- /public/column/index.js: -------------------------------------------------------------------------------- 1 | function fireLog(e) { 2 | e.preventDefault(); 3 | const url = e.currentTarget.getAttribute('data-href'); 4 | fetch(url) 5 | .then(response => response.text()) 6 | .then(data => { 7 | const logModal = document.getElementById('log-modal'); 8 | const doc = new DOMParser().parseFromString(data, 'text/html'); 9 | setHTMLWithScript('.modal-body', doc.querySelector('.page').innerHTML); 10 | var modal = new bootstrap.Modal(logModal); 11 | modal.show(); 12 | logModal.addEventListener('hide.bs.modal', () => { 13 | for (let i = 0; i < 10000; i++) { 14 | window.clearInterval(i); 15 | } 16 | }) 17 | }) 18 | } 19 | 20 | function setHTMLWithScript(selector, html) { 21 | const container = document.querySelector(selector); 22 | container.innerHTML = html; 23 | 24 | const scripts = container.querySelectorAll("script"); 25 | scripts.forEach(oldScript => { 26 | const newScript = document.createElement("script"); 27 | if (oldScript.src) { 28 | newScript.src = oldScript.src; 29 | } else { 30 | newScript.textContent = oldScript.textContent; 31 | } 32 | oldScript.replaceWith(newScript); 33 | }); 34 | } -------------------------------------------------------------------------------- /public/controller/index.css: -------------------------------------------------------------------------------- 1 | #results { 2 | padding: 5px; 3 | overflow-y: scroll; 4 | height: calc(100% - 150px); 5 | } 6 | 7 | #log-modal .description, 8 | #log-modal > .content, 9 | #log-modal #content { 10 | height: 100%; 11 | } 12 | 13 | #results .loader:before { 14 | border-color: rgba(0,0,0,.1); 15 | } 16 | 17 | #results .loader:after { 18 | border-color: #767676 transparent transparent; 19 | } 20 | -------------------------------------------------------------------------------- /public/controller/index.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | let lastSize = 0; 3 | let grep = ""; 4 | let invert = 0; 5 | let documentHeight = 0; 6 | let scrollPosition = 0; 7 | let scroll = false; 8 | 9 | const grepInput = document.getElementById("grep"); 10 | const results = document.getElementById("results"); 11 | const floatElements = document.querySelectorAll(".float"); 12 | 13 | // Handle "Enter" keyup in #grep 14 | grepInput.addEventListener("keyup", function (e) { 15 | if (e.key === "Enter") { 16 | lastSize = 0; 17 | grep = this.value; 18 | results.innerHTML = ""; 19 | } 20 | }); 21 | 22 | // Focus on #grep input 23 | grepInput.focus(); 24 | // Periodic log update 25 | const updateLog = setInterval(function () { 26 | fetch(`${sy_route}?refresh=1&lastsize=${lastSize}&grep-keywords=${grep}&invert=${invert}`) 27 | .then(response => response.json()) 28 | .then(data => { 29 | lastSize = data.size; 30 | data.data.forEach(value => { 31 | const entry = document.createElement("div"); 32 | entry.innerHTML = value + "
"; 33 | results.prepend(entry); 34 | }); 35 | 36 | if (scroll) { 37 | scrollToBottom(); 38 | } 39 | 40 | if (data.data.length < 1) { 41 | const loader = results.querySelector(".loader"); 42 | if (loader) loader.remove(); 43 | } 44 | }) 45 | .catch(error => { 46 | results.innerHTML += error.message; 47 | clearInterval(updateLog); 48 | }); 49 | }, sy_updateTime); 50 | 51 | // Fix float element on scroll 52 | window.addEventListener("scroll", function () { 53 | const scrollTop = window.scrollY; 54 | floatElements.forEach(el => { 55 | if (scrollTop > 0) { 56 | el.style.position = "fixed"; 57 | el.style.top = "0"; 58 | el.style.left = "auto"; 59 | } else { 60 | el.style.position = "static"; 61 | } 62 | }); 63 | 64 | documentHeight = document.documentElement.scrollHeight; 65 | scrollPosition = window.innerHeight + window.scrollY; 66 | scroll = documentHeight <= scrollPosition; 67 | }); 68 | 69 | // Scroll to bottom on resize if needed 70 | window.addEventListener("resize", function () { 71 | if (scroll) { 72 | scrollToBottom(); 73 | } 74 | }); 75 | 76 | scrollToBottom(); 77 | 78 | // Scroll to bottom function 79 | function scrollToBottom() { 80 | window.scrollTo({ 81 | top: document.body.scrollHeight, 82 | behavior: "smooth" 83 | }); 84 | } 85 | })(); -------------------------------------------------------------------------------- /src/Action/CleanLogAction.php: -------------------------------------------------------------------------------- 1 | ['permission' => true]], methods: ['GET'])] 18 | public function __invoke( 19 | TranslatorInterface $translator, 20 | CommandRepository $commandRepository, 21 | string $command, 22 | string $logsDir, 23 | ): Response { 24 | /** @var ScheduledCommand|null $scheduleCommand */ 25 | $scheduleCommand = $commandRepository->find($command); 26 | 27 | if (null === $scheduleCommand) { 28 | $this->addFlash('error', $translator->trans('sylius.ui.scheduled_command_not_exists')); 29 | 30 | return $this->redirectToGrid(); 31 | } 32 | 33 | if (null === $scheduleCommand->getLogFile()) { 34 | $this->addFlash('error', $translator->trans('sylius.ui.log_file_undefined')); 35 | 36 | return $this->redirectToGrid(); 37 | } 38 | 39 | $filePath = $logsDir . \DIRECTORY_SEPARATOR . $scheduleCommand->getLogFile(); 40 | if (!\file_exists($filePath)) { 41 | $this->addFlash('error', $translator->trans('sylius.ui.no_log_file_found')); 42 | 43 | return $this->redirectToGrid(); 44 | } 45 | 46 | try { 47 | file_put_contents($filePath, ''); 48 | } catch (\Throwable) { 49 | $this->addFlash('error', $translator->trans('sylius.ui.error_emptying_log_file')); 50 | } 51 | 52 | $this->addFlash('success', $translator->trans('sylius.ui.log_file_successfully_emptied')); 53 | 54 | return $this->redirectToGrid(); 55 | } 56 | 57 | private function redirectToGrid(): RedirectResponse 58 | { 59 | return $this->redirectToRoute( 60 | 'synolia_admin_command_index', 61 | [], 62 | Response::HTTP_MOVED_PERMANENTLY, 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Checker/EveryMinuteIsDueChecker.php: -------------------------------------------------------------------------------- 1 | dateTimeProvider?->now() ?? new \DateTime(); 38 | } 39 | 40 | $cron = new CronExpression($command->getCronExpression()); 41 | 42 | if (!$cron->isDue($dateTime)) { 43 | throw new IsNotDueException(); 44 | } 45 | 46 | return true; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Checker/IsDueCheckerInterface.php: -------------------------------------------------------------------------------- 1 | dateTimeProvider?->now() ?? new \DateTime(); 41 | } 42 | 43 | $cron = new CronExpression($command->getCronExpression()); 44 | if ($cron->isDue($dateTime)) { 45 | return true; 46 | } 47 | 48 | $previousRunDate = $cron->getPreviousRunDate(); 49 | $previousRunDateThreshold = (clone $previousRunDate)->add(new \DateInterval(\sprintf('PT%dM', $this->threshold))); 50 | 51 | $lastCreatedScheduledCommand = $this->scheduledCommandRepository->findLastCreatedCommand($command); 52 | 53 | // if never, do my command is valid for the least "threshold" minutes 54 | if (!$lastCreatedScheduledCommand instanceof \Synolia\SyliusSchedulerCommandPlugin\Entity\ScheduledCommandInterface) { 55 | if ($dateTime->getTimestamp() >= $previousRunDate->getTimestamp() && $dateTime->getTimestamp() <= $previousRunDateThreshold->getTimestamp()) { 56 | return true; 57 | } 58 | 59 | throw new IsNotDueException(); 60 | } 61 | 62 | // check if last command has been started since scheduled datetime +0..5 minutes 63 | if ( 64 | $lastCreatedScheduledCommand->getCreatedAt()->getTimestamp() >= $previousRunDate->getTimestamp() && 65 | $lastCreatedScheduledCommand->getCreatedAt()->getTimestamp() <= $previousRunDateThreshold->getTimestamp() && 66 | $dateTime->getTimestamp() <= $previousRunDateThreshold->getTimestamp() 67 | ) { 68 | throw new IsNotDueException(); 69 | } 70 | 71 | if ( 72 | $dateTime->getTimestamp() >= $previousRunDate->getTimestamp() && 73 | $dateTime->getTimestamp() <= $previousRunDateThreshold->getTimestamp() 74 | ) { 75 | return true; 76 | } 77 | 78 | throw new IsNotDueException(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Command/PurgeScheduledCommandCommand.php: -------------------------------------------------------------------------------- 1 | addOption('all', 'p', InputOption::VALUE_NONE, 'Remove all schedules with specified state (default is finished).') 43 | ->addOption('days', 'd', InputOption::VALUE_OPTIONAL, '{X} days old', self::DEFAULT_PURGE_PERIODE_IN_DAYS) 44 | ->addOption('state', 's', InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'State of scheduled history to be cleaned', [self::DEFAULT_STATE]) 45 | ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Dry run') 46 | ; 47 | } 48 | 49 | protected function execute(InputInterface $input, OutputInterface $output): int 50 | { 51 | $this->io = new SymfonyStyle($input, $output); 52 | 53 | $purgeAll = $input->getOption('all'); 54 | $daysOld = (int) $input->getOption('days'); 55 | $state = $input->getOption('state'); 56 | /** @var bool $dryRun */ 57 | $dryRun = $input->getOption('dry-run') ?? false; 58 | 59 | /** @var ScheduledCommandInterface[] $schedules */ 60 | $schedules = $this->getScheduledHistory($purgeAll, $daysOld, $state); 61 | 62 | $counter = 0; 63 | foreach ($schedules as $schedule) { 64 | $this->logger->info(\sprintf( 65 | 'Removed scheduled command "%s" (%d)', 66 | $schedule->getName(), 67 | $schedule->getId(), 68 | )); 69 | 70 | if ($dryRun) { 71 | continue; 72 | } 73 | 74 | $this->entityManager->remove($schedule); 75 | 76 | if ($counter % self::DEFAULT_BATCH === 0) { 77 | $this->entityManager->flush(); 78 | } 79 | ++$counter; 80 | } 81 | 82 | $this->entityManager->flush(); 83 | 84 | return 0; 85 | } 86 | 87 | private function getScheduledHistory(bool $purgeAll, int $daysOld, array $states): iterable 88 | { 89 | if ($purgeAll) { 90 | $this->io->note(\sprintf( 91 | 'All schedules with states ["%s"] will be purged.', 92 | \implode(',', $states), 93 | )); 94 | 95 | return $this->scheduledCommandRepository->findAllHavingState($states); 96 | } 97 | 98 | $maxDate = new \DateTime(); 99 | $maxDate->modify(\sprintf('-%d days', $daysOld)); 100 | 101 | $this->io->note(\sprintf( 102 | 'Schedules with states ["%s"] lesser than %s days(s) (%s) will be purged.', 103 | \implode(',', $states), 104 | $daysOld, 105 | $maxDate->format('Y-m-d'), 106 | )); 107 | 108 | return $this->scheduledCommandRepository->findAllSinceXDaysWithState($maxDate, $states); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Command/SynoliaSchedulerRunCommand.php: -------------------------------------------------------------------------------- 1 | addOption('id', 'i', InputOption::VALUE_OPTIONAL, 'Command ID'); 47 | $this->addOption('only-one', 'o', InputOption::VALUE_NONE, 'Launch only one command'); 48 | } 49 | 50 | protected function execute(InputInterface $input, OutputInterface $output): int 51 | { 52 | $io = new SymfonyStyle($input, $output); 53 | 54 | $scheduledCommandId = $input->getOption('id'); 55 | 56 | if (null !== $scheduledCommandId) { 57 | /** @var ScheduledCommandInterface|null $scheduledCommand */ 58 | $scheduledCommand = $this->scheduledCommandRepository->find((int) $scheduledCommandId); 59 | 60 | if (!$scheduledCommand instanceof ScheduledCommandInterface) { 61 | return Command::SUCCESS; 62 | } 63 | 64 | $this->executeCommand($scheduledCommand, $io); 65 | 66 | return Command::SUCCESS; 67 | } 68 | 69 | $commands = $this->getCommands($input); 70 | 71 | /** @var CommandInterface $command */ 72 | foreach ($commands as $command) { 73 | // delayed execution just after, to keep cron comparison effective 74 | if ($this->shouldExecuteCommand($command, $io)) { 75 | $this->scheduledCommandPlanner->plan($command); 76 | // The execution is planned, and does not need to be launched again in the future. 77 | $command->setExecuteImmediately(false); 78 | } 79 | } 80 | 81 | if (!$this->lock()) { 82 | $output->writeln('The command is already running in another process.'); 83 | $this->logger->info('Scheduler is already running.'); 84 | 85 | return Command::SUCCESS; 86 | } 87 | 88 | /** @var ScheduledCommandInterface[] $scheduledCommands */ 89 | $scheduledCommands = $this->scheduledCommandRepository->findAllRunnable(); 90 | 91 | if (0 === \count($scheduledCommands)) { 92 | $io->success('Nothing to do.'); 93 | } 94 | 95 | foreach ($scheduledCommands as $scheduledCommand) { 96 | $io->note(\sprintf( 97 | 'Execute Command "%s"', 98 | $scheduledCommand->getCommand(), 99 | )); 100 | 101 | try { 102 | $this->runScheduledCommand($io, $scheduledCommand); 103 | } catch (ConnectionLost) { 104 | $this->runScheduledCommand($io, $scheduledCommand); 105 | } 106 | 107 | if (true === $input->getOption('only-one')) { 108 | break; 109 | } 110 | } 111 | 112 | $this->release(); 113 | 114 | return Command::SUCCESS; 115 | } 116 | 117 | private function runScheduledCommand(SymfonyStyle $io, ScheduledCommandInterface $scheduledCommand): void 118 | { 119 | /** prevent update during running time */ 120 | $this->scheduledCommandRepository->find($scheduledCommand->getId()); 121 | 122 | $this->executeCommand($scheduledCommand, $io); 123 | } 124 | 125 | private function executeCommand(ScheduledCommandInterface $scheduledCommand, SymfonyStyle $io): void 126 | { 127 | try { 128 | /** @var Application $application */ 129 | $application = $this->getApplication(); 130 | $command = $application->find($scheduledCommand->getCommand()); 131 | } catch (\InvalidArgumentException $e) { 132 | $scheduledCommand->setLastReturnCode(-1); 133 | //persist last return code 134 | $this->entityManager->flush(); 135 | $io->error('Cannot find ' . $scheduledCommand->getCommand()); 136 | 137 | return; 138 | } 139 | 140 | // Execute command and get return code 141 | try { 142 | $io->writeln( 143 | 'Execute : ' . $scheduledCommand->getCommand() 144 | . ' ' . $scheduledCommand->getArguments() . '', 145 | ); 146 | 147 | $scheduledCommand->setExecutedAt(new \DateTime()); 148 | $this->changeState($scheduledCommand, ScheduledCommandStateEnum::IN_PROGRESS); 149 | $result = $this->scheduleCommandRunner->runFromCron($scheduledCommand); 150 | 151 | try { 152 | $this->changeState($scheduledCommand, $this->getStateForResult($result)); 153 | } catch (ConnectionLost) { 154 | $this->changeState($scheduledCommand, $this->getStateForResult($result)); 155 | } 156 | } catch (\Exception $e) { 157 | $this->changeState($scheduledCommand, ScheduledCommandStateEnum::ERROR); 158 | $io->warning($e->getMessage()); 159 | $result = -1; 160 | } 161 | 162 | $scheduledCommand->setLastReturnCode($result); 163 | $this->entityManager->flush(); 164 | 165 | /* 166 | * This clear() is necessary to avoid conflict between commands 167 | * and to be sure that none entity are managed before entering in a new command 168 | */ 169 | $this->entityManager->clear(); 170 | 171 | unset($command); 172 | gc_collect_cycles(); 173 | } 174 | 175 | private function getCommands(InputInterface $input): iterable 176 | { 177 | $commands = $this->commandRepository->findEnabledCommand(); 178 | if ($input->getOption('id') !== null) { 179 | $commands = $this->scheduledCommandRepository->findBy(['id' => $input->getOption('id')]); 180 | } 181 | 182 | return $commands; 183 | } 184 | 185 | private function shouldExecuteCommand(CommandInterface $command, SymfonyStyle $io): bool 186 | { 187 | if ($command->isExecuteImmediately()) { 188 | $io->note('Immediately execution asked for : ' . $command->getCommand()); 189 | 190 | return true; 191 | } 192 | 193 | // Could be removed as getCommands fetch only enabled commands 194 | if (!$command->isEnabled()) { 195 | return false; 196 | } 197 | 198 | return $this->isDueVoter->isDue($command); 199 | } 200 | 201 | private function changeState(ScheduledCommandInterface $scheduledCommand, string $state): void 202 | { 203 | $scheduledCommand->setState($state); 204 | $this->entityManager->flush(); 205 | } 206 | 207 | private function getStateForResult(int $returnResultCode): string 208 | { 209 | if ($returnResultCode === 143) { 210 | return ScheduledCommandStateEnum::TERMINATION; 211 | } 212 | 213 | if ($returnResultCode !== 0) { 214 | return ScheduledCommandStateEnum::ERROR; 215 | } 216 | 217 | return ScheduledCommandStateEnum::FINISHED; 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/Components/Exceptions/Checker/IsNotDueException.php: -------------------------------------------------------------------------------- 1 | ['permission' => true]], methods: ['GET'])] 23 | public function downloadLogFile(string $command): Response 24 | { 25 | $scheduleCommand = $this->scheduledCommandRepository->find($command); 26 | 27 | if ( 28 | null === $scheduleCommand || 29 | null === $scheduleCommand->getLogFile() 30 | ) { 31 | return new Response('', Response::HTTP_NOT_FOUND); 32 | } 33 | 34 | $filePath = $this->logsDir . \DIRECTORY_SEPARATOR . $scheduleCommand->getLogFile(); 35 | if (!\file_exists($filePath)) { 36 | return new Response('', Response::HTTP_NOT_FOUND); 37 | } 38 | 39 | return $this->file($filePath); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Controller/EmptyLogsController.php: -------------------------------------------------------------------------------- 1 | ['permission' => true, 'repository' => ['method' => 'findById', 'arguments' => ['$ids']]]], methods: ['GET|PUT'])] 26 | public function emptyLogs(Request $request): Response 27 | { 28 | $commandIds = $request->get('ids'); 29 | 30 | foreach ($commandIds as $commandId) { 31 | $command = $this->scheduledCommandRepository->find($commandId); 32 | if ($command !== null && $command->getLogFile() !== null) { 33 | @\file_put_contents($this->logsDir . \DIRECTORY_SEPARATOR . $command->getLogFile(), ''); 34 | } 35 | } 36 | 37 | $this->addFlash('success', $this->translator->trans('sylius.ui.scheduled_command.bulk_empty_logs')); 38 | 39 | return $this->redirectToRoute( 40 | 'synolia_admin_command_index', 41 | [], 42 | Response::HTTP_MOVED_PERMANENTLY, 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Controller/LogViewerController.php: -------------------------------------------------------------------------------- 1 | ['permission' => true]], methods: ['GET'])] 31 | public function getLogs(Request $request, string $command): JsonResponse 32 | { 33 | $scheduleCommand = $this->scheduledCommandRepository->find($command); 34 | 35 | if ( 36 | null === $scheduleCommand || 37 | null === $scheduleCommand->getLogFile() 38 | ) { 39 | return new JsonResponse('', Response::HTTP_NO_CONTENT); 40 | } 41 | 42 | if ((bool) $request->get('refresh')) { 43 | $result = $this->logDataRetriever->getLog( 44 | $this->logsDir . \DIRECTORY_SEPARATOR . $scheduleCommand->getLogFile(), 45 | (int) $request->get('lastsize'), 46 | (string) $request->get('grep-keywords'), 47 | (bool) $request->get('invert'), 48 | ); 49 | 50 | return new JsonResponse([ 51 | 'size' => $result['size'], 52 | 'data' => $result['data'], 53 | ]); 54 | } 55 | 56 | $result = $this->logDataRetriever->getLog($this->logsDir . \DIRECTORY_SEPARATOR . $scheduleCommand->getLogFile()); 57 | 58 | return new JsonResponse([ 59 | 'size' => $result['size'], 60 | 'data' => $result['data'], 61 | ]); 62 | } 63 | 64 | #[Route('/scheduled-commands/{command}/view-log', name: 'sylius_admin_scheduler_view_log_file', defaults: ['_sylius' => ['permission' => true]], methods: ['GET'])] 65 | public function show(string $command): Response 66 | { 67 | $scheduledCommand = $this->scheduledCommandRepository->find($command); 68 | 69 | if ( 70 | null === $scheduledCommand || 71 | null === $scheduledCommand->getLogFile() 72 | ) { 73 | $this->addFlash('error', $this->translator->trans('sylius.ui.does_not_exists_or_missing_log_file')); 74 | 75 | return $this->redirectToRoute('synolia_admin_command_index'); 76 | } 77 | 78 | return $this->render('@SynoliaSyliusSchedulerCommandPlugin/Controller/show.html.twig', [ 79 | 'route' => $this->generateUrl('sylius_admin_scheduler_get_log_file', ['command' => $command]), 80 | 'updateTime' => $this->updateTime, 81 | 'scheduledCommand' => $scheduledCommand, 82 | ]); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Controller/ScheduledCommandExecuteImmediateController.php: -------------------------------------------------------------------------------- 1 | ['permission' => true]], methods: ['GET|PUT'])] 31 | public function executeImmediate(Request $request, string $commandId): Response 32 | { 33 | $command = $this->commandRepository->find($commandId); 34 | Assert::isInstanceOf($command, CommandInterface::class); 35 | 36 | $scheduledCommand = $this->scheduledCommandPlanner->plan($command); 37 | 38 | $this->executeFromCron($scheduledCommand); 39 | 40 | /** @var Session $session */ 41 | $session = $request->getSession(); 42 | $session->getFlashBag()->add('success', \sprintf( 43 | 'Command "%s" as been planned for execution.', 44 | $scheduledCommand->getName(), 45 | )); 46 | 47 | return $this->redirectToRoute('synolia_admin_command_index'); 48 | } 49 | 50 | public function executeFromCron(ScheduledCommandInterface $scheduledCommand): int 51 | { 52 | $process = Process::fromShellCommandline($this->getCommandLine($scheduledCommand)); 53 | $process->setTimeout($scheduledCommand->getTimeout()); 54 | $process->setIdleTimeout($scheduledCommand->getIdleTimeout()); 55 | $process->run(); 56 | $result = $process->getExitCode(); 57 | $scheduledCommand->setCommandEndTime(new \DateTime()); 58 | 59 | if (null === $result) { 60 | $result = 0; 61 | } 62 | 63 | return $result; 64 | } 65 | 66 | private function getCommandLine(ScheduledCommandInterface $scheduledCommand): string 67 | { 68 | return sprintf( 69 | '%s/bin/console synolia:scheduler-run --id=%d > /dev/null 2>&1 &', 70 | $this->projectDir, 71 | $scheduledCommand->getId(), 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/DataRetriever/LogDataRetriever.php: -------------------------------------------------------------------------------- 1 | $this->maxSizeToLoad) { 34 | $maxLength = ($this->maxSizeToLoad / 2); 35 | } 36 | 37 | /** 38 | * Actually load the data 39 | */ 40 | $data = []; 41 | if ($maxLength > 0) { 42 | $filePointer = \fopen($filePath, 'rb'); 43 | 44 | if (false === $filePointer) { 45 | throw new FileNotFoundException('Could not load data for the log file.'); 46 | } 47 | 48 | \fseek($filePointer, (int) -$maxLength, \SEEK_END); 49 | $data = \explode("\n", (string) \fread($filePointer, $maxLength)); 50 | } 51 | $data = $this->grepData($data, $grepKeyword, $invertGrep); 52 | 53 | /** 54 | * If the last entry in the array is an empty string lets remove it. 55 | */ 56 | if (\end($data) === '') { 57 | \array_pop($data); 58 | } 59 | 60 | return [ 61 | 'size' => $filesize, 62 | 'file' => $filePath, 63 | 'data' => $data, 64 | ]; 65 | } 66 | 67 | /** 68 | * Run the grep function to return only the lines we're interested in. 69 | */ 70 | private function grepData(array $data, string $grepKeyword, bool $invertGrep): array 71 | { 72 | $lines = preg_grep("/$grepKeyword/", $data, $invertGrep ? \PREG_GREP_INVERT : 0); 73 | 74 | return \is_array($lines) ? $lines : []; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/DependencyInjection/SynoliaSyliusSchedulerCommandExtension.php: -------------------------------------------------------------------------------- 1 | load('services.yaml'); 25 | } 26 | 27 | /** 28 | * @inheritdoc 29 | */ 30 | public function prepend(ContainerBuilder $container): void 31 | { 32 | $this->prependDoctrineMigrations($container); 33 | } 34 | 35 | protected function getMigrationsNamespace(): string 36 | { 37 | return 'Synolia\SyliusSchedulerCommandPlugin\Migrations'; 38 | } 39 | 40 | protected function getMigrationsDirectory(): string 41 | { 42 | return '@SynoliaSyliusSchedulerCommandPlugin/migrations'; 43 | } 44 | 45 | protected function getNamespacesOfMigrationsExecutedBefore(): array 46 | { 47 | return []; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/DoctrineEvent/ScheduledCommandPostRemoveEvent.php: -------------------------------------------------------------------------------- 1 | 'doctrine.event_subscriber', 'connection' => 'default'])] 15 | class ScheduledCommandPostRemoveEvent implements EventSubscriber 16 | { 17 | public function __construct( 18 | #[Autowire(param: 'kernel.logs_dir')] 19 | private readonly string $logsDir, 20 | ) { 21 | } 22 | 23 | public function getSubscribedEvents(): array 24 | { 25 | return [ 26 | Events::postRemove, 27 | ]; 28 | } 29 | 30 | public function postRemove(LifecycleEventArgs $eventArgs): void 31 | { 32 | $scheduledCommand = $eventArgs->getEntity(); 33 | 34 | if (!$scheduledCommand instanceof ScheduledCommandInterface) { 35 | return; 36 | } 37 | 38 | if (null === $scheduledCommand->getLogFile() || '' === $scheduledCommand->getLogFile()) { 39 | return; 40 | } 41 | 42 | $filePath = $this->logsDir . \DIRECTORY_SEPARATOR . $scheduledCommand->getLogFile(); 43 | 44 | if (!\file_exists($filePath)) { 45 | return; 46 | } 47 | 48 | @unlink($filePath); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Entity/Command.php: -------------------------------------------------------------------------------- 1 | 90 | * 91 | * @ORM\OneToMany(targetEntity="Synolia\SyliusSchedulerCommandPlugin\Entity\ScheduledCommandInterface", mappedBy="owner") 92 | */ 93 | #[ORM\OneToMany(mappedBy: 'owner', targetEntity: ScheduledCommandInterface::class)] 94 | private Collection $scheduledCommands; 95 | 96 | public function __construct() 97 | { 98 | $this->scheduledCommands = new ArrayCollection(); 99 | } 100 | 101 | public function getId(): ?int 102 | { 103 | return $this->id; 104 | } 105 | 106 | public function getName(): string 107 | { 108 | return $this->name; 109 | } 110 | 111 | public function setName(string $name): self 112 | { 113 | $this->name = $name; 114 | 115 | return $this; 116 | } 117 | 118 | public function getCommand(): string 119 | { 120 | return $this->command; 121 | } 122 | 123 | public function setCommand(string $command): self 124 | { 125 | $this->command = $command; 126 | 127 | return $this; 128 | } 129 | 130 | public function getArguments(): ?string 131 | { 132 | return $this->arguments; 133 | } 134 | 135 | public function setArguments(?string $arguments): self 136 | { 137 | $this->arguments = $arguments; 138 | 139 | return $this; 140 | } 141 | 142 | public function getCronExpression(): string 143 | { 144 | return $this->cronExpression; 145 | } 146 | 147 | public function setCronExpression(string $cronExpression): self 148 | { 149 | $this->cronExpression = $cronExpression; 150 | 151 | return $this; 152 | } 153 | 154 | public function getLogFilePrefix(): ?string 155 | { 156 | return $this->logFilePrefix; 157 | } 158 | 159 | public function setLogFilePrefix(?string $logFilePrefix): self 160 | { 161 | $this->logFilePrefix = $logFilePrefix; 162 | 163 | return $this; 164 | } 165 | 166 | public function getPriority(): int 167 | { 168 | return $this->priority; 169 | } 170 | 171 | public function setPriority(int $priority): self 172 | { 173 | $this->priority = $priority; 174 | 175 | return $this; 176 | } 177 | 178 | public function isExecuteImmediately(): bool 179 | { 180 | return $this->executeImmediately; 181 | } 182 | 183 | public function setExecuteImmediately(bool $executeImmediately): self 184 | { 185 | $this->executeImmediately = $executeImmediately; 186 | 187 | return $this; 188 | } 189 | 190 | public function isEnabled(): bool 191 | { 192 | return $this->enabled; 193 | } 194 | 195 | public function setEnabled(bool $enabled): self 196 | { 197 | $this->enabled = $enabled; 198 | 199 | return $this; 200 | } 201 | 202 | public function getScheduledCommands(): Collection 203 | { 204 | return $this->scheduledCommands; 205 | } 206 | 207 | public function addScheduledCommand(ScheduledCommandInterface $scheduledCommand): self 208 | { 209 | if ($this->scheduledCommands->contains($scheduledCommand)) { 210 | return $this; 211 | } 212 | 213 | $this->scheduledCommands->add($scheduledCommand); 214 | 215 | return $this; 216 | } 217 | 218 | public function removeScheduledCommand(ScheduledCommandInterface $scheduledCommand): self 219 | { 220 | if (!$this->scheduledCommands->contains($scheduledCommand)) { 221 | return $this; 222 | } 223 | 224 | $this->scheduledCommands->removeElement($scheduledCommand); 225 | // needed to update the owning side of the relationship! 226 | $scheduledCommand->setOwner(null); 227 | 228 | return $this; 229 | } 230 | 231 | public function getTimeout(): ?int 232 | { 233 | return $this->timeout; 234 | } 235 | 236 | public function setTimeout(?int $timeout): CommandInterface 237 | { 238 | $this->timeout = $timeout; 239 | 240 | return $this; 241 | } 242 | 243 | public function getIdleTimeout(): ?int 244 | { 245 | return $this->idleTimeout; 246 | } 247 | 248 | public function setIdleTimeout(?int $idleTimeout): CommandInterface 249 | { 250 | $this->idleTimeout = $idleTimeout; 251 | 252 | return $this; 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /src/Entity/CommandInterface.php: -------------------------------------------------------------------------------- 1 | createdAt = new \DateTime(); 95 | } 96 | 97 | public function getId(): ?int 98 | { 99 | return $this->id; 100 | } 101 | 102 | public function getName(): string 103 | { 104 | return $this->name; 105 | } 106 | 107 | public function setName(string $name): ScheduledCommandInterface 108 | { 109 | $this->name = $name; 110 | 111 | return $this; 112 | } 113 | 114 | public function getCommand(): string 115 | { 116 | return $this->command; 117 | } 118 | 119 | public function setCommand(string $command): ScheduledCommandInterface 120 | { 121 | $this->command = $command; 122 | 123 | return $this; 124 | } 125 | 126 | public function getArguments(): ?string 127 | { 128 | return $this->arguments; 129 | } 130 | 131 | public function setArguments(?string $arguments): ScheduledCommandInterface 132 | { 133 | $this->arguments = $arguments; 134 | 135 | return $this; 136 | } 137 | 138 | public function getExecutedAt(): ?\DateTime 139 | { 140 | return $this->executedAt; 141 | } 142 | 143 | public function setExecutedAt(?\DateTime $executedAt): ScheduledCommandInterface 144 | { 145 | $this->executedAt = $executedAt; 146 | 147 | return $this; 148 | } 149 | 150 | public function getLastReturnCode(): ?int 151 | { 152 | return $this->lastReturnCode; 153 | } 154 | 155 | public function setLastReturnCode(?int $lastReturnCode): ScheduledCommandInterface 156 | { 157 | $this->lastReturnCode = $lastReturnCode; 158 | 159 | return $this; 160 | } 161 | 162 | public function getLogFile(): ?string 163 | { 164 | return $this->logFile; 165 | } 166 | 167 | public function setLogFile(?string $logFile): ScheduledCommandInterface 168 | { 169 | $this->logFile = $logFile; 170 | 171 | return $this; 172 | } 173 | 174 | public function getCommandEndTime(): ?\DateTime 175 | { 176 | return $this->commandEndTime; 177 | } 178 | 179 | public function setCommandEndTime(?\DateTime $commandEndTime): ScheduledCommandInterface 180 | { 181 | $this->commandEndTime = $commandEndTime; 182 | 183 | return $this; 184 | } 185 | 186 | public function getCreatedAt(): \DateTime 187 | { 188 | return $this->createdAt; 189 | } 190 | 191 | public function setState(string $state): self 192 | { 193 | $this->state = $state; 194 | 195 | return $this; 196 | } 197 | 198 | public function getState(): string 199 | { 200 | return $this->state; 201 | } 202 | 203 | public function getOwner(): ?CommandInterface 204 | { 205 | return $this->owner; 206 | } 207 | 208 | public function setOwner(?CommandInterface $owner): self 209 | { 210 | $this->owner = $owner; 211 | 212 | return $this; 213 | } 214 | 215 | public function getTimeout(): ?int 216 | { 217 | return $this->timeout; 218 | } 219 | 220 | public function setTimeout(?int $timeout): ScheduledCommandInterface 221 | { 222 | $this->timeout = $timeout; 223 | 224 | return $this; 225 | } 226 | 227 | public function getIdleTimeout(): ?int 228 | { 229 | return $this->idleTimeout; 230 | } 231 | 232 | public function setIdleTimeout(?int $idleTimeout): ScheduledCommandInterface 233 | { 234 | $this->idleTimeout = $idleTimeout; 235 | 236 | return $this; 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /src/Entity/ScheduledCommandInterface.php: -------------------------------------------------------------------------------- 1 | 'onConsoleTerminate', 27 | ConsoleEvents::SIGNAL => 'onConsoleSignal', 28 | ]; 29 | } 30 | 31 | public function onConsoleTerminate(ConsoleTerminateEvent $event): void 32 | { 33 | $this->updateCommand($event); 34 | } 35 | 36 | public function onConsoleSignal(ConsoleSignalEvent $event): void 37 | { 38 | $this->updateCommand($event); 39 | } 40 | 41 | private function updateCommand(ConsoleSignalEvent|ConsoleTerminateEvent $event): void 42 | { 43 | try { 44 | $commandCode = $event->getCommand()?->getName() ?? 'no_command'; 45 | /** @var ScheduledCommand|null $schedulerCommand */ 46 | $schedulerCommand = $this->scheduledCommandRepository->findOneBy(['command' => $commandCode], ['id' => 'DESC']); 47 | } catch (Exception) { 48 | return; 49 | } 50 | 51 | if (null === $schedulerCommand) { 52 | return; 53 | } 54 | 55 | if ($schedulerCommand->getState() !== ScheduledCommandStateEnum::IN_PROGRESS) { 56 | return; 57 | } 58 | 59 | $exitCode = $event->getExitCode(); 60 | if (false === $exitCode) { 61 | $exitCode = -1; 62 | } 63 | 64 | $schedulerCommand->setCommandEndTime(new \DateTime()); 65 | $schedulerCommand->setState(ScheduledCommandStateEnum::TERMINATION); 66 | $schedulerCommand->setLastReturnCode($exitCode); 67 | $this->scheduledCommandRepository->add($schedulerCommand); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Fixture/SchedulerCommandFixture.php: -------------------------------------------------------------------------------- 1 | commandFactory->createNew(); 33 | $command 34 | ->setName($commandArray['name']) 35 | ->setCommand($commandArray['command']) 36 | ->setArguments($commandArray['arguments']) 37 | ->setCronExpression($commandArray['cronExpression']) 38 | ->setLogFilePrefix($commandArray['logFilePrefix']) 39 | ->setPriority($commandArray['priority']) 40 | ->setTimeout($commandArray['timeout'] ?? null) 41 | ->setIdleTimeout($commandArray['idle_timeout'] ?? null) 42 | ->setExecuteImmediately($commandArray['executeImmediately']) 43 | ->setEnabled($commandArray['enabled']) 44 | ; 45 | $this->commandRepository->add($command); 46 | } 47 | } 48 | 49 | /** 50 | * @inheritdoc 51 | */ 52 | public function getName(): string 53 | { 54 | return 'scheduler_command'; 55 | } 56 | 57 | protected function configureOptionsNode(ArrayNodeDefinition $optionsNode): void 58 | { 59 | $optionsNode 60 | ->children() 61 | ->arrayNode('scheduled_commands')->arrayPrototype()->children() 62 | ->scalarNode('name')->isRequired()->end() 63 | ->scalarNode('command')->isRequired()->end() 64 | ->scalarNode('arguments')->defaultValue('')->end() 65 | ->scalarNode('cronExpression')->isRequired()->end() 66 | ->scalarNode('logFilePrefix')->defaultValue('')->end() 67 | ->integerNode('priority')->isRequired()->end() 68 | ->integerNode('timeout')->defaultNull()->end() 69 | ->integerNode('idle_timeout')->defaultNull()->end() 70 | ->booleanNode('executeImmediately')->defaultFalse()->end() 71 | ->booleanNode('enabled')->defaultTrue()->end() 72 | ->end() 73 | ->end() 74 | ; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Form/CommandChoiceType.php: -------------------------------------------------------------------------------- 1 | setDefaults( 21 | [ 22 | 'choices' => $this->commandParser->getCommands(), 23 | ], 24 | ); 25 | } 26 | 27 | public function getParent(): string 28 | { 29 | return ChoiceType::class; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Form/CommandType.php: -------------------------------------------------------------------------------- 1 | add('name', TextType::class, [ 24 | 'empty_data' => '', 25 | ]) 26 | ->add('command', CommandChoiceType::class) 27 | ->add('arguments') 28 | ->add('cronExpression') 29 | ->add('logFilePrefix') 30 | ->add('priority', IntegerType::class, [ 31 | 'empty_data' => 0, 32 | ]) 33 | ->add('timeout', IntegerType::class, [ 34 | 'required' => false, 35 | 'constraints' => [ 36 | new Constraints\PositiveOrZero(), 37 | ], 38 | 'attr' => [ 39 | 'min' => 0, 40 | ], 41 | ]) 42 | ->add('idle_timeout', IntegerType::class, [ 43 | 'required' => false, 44 | 'constraints' => [ 45 | new Constraints\PositiveOrZero(), 46 | ], 47 | 'attr' => [ 48 | 'min' => 0, 49 | ], 50 | ]) 51 | ->add('executeImmediately') 52 | ->add('enabled') 53 | ; 54 | } 55 | 56 | public function configureOptions(OptionsResolver $resolver): void 57 | { 58 | $resolver->setDefaults([ 59 | 'data_class' => CommandInterface::class, 60 | ]); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Form/ScheduledCommandType.php: -------------------------------------------------------------------------------- 1 | add('name', TextType::class, [ 24 | 'empty_data' => '', 25 | ]) 26 | ->add('command', CommandChoiceType::class) 27 | ->add('arguments') 28 | ->add('timeout', IntegerType::class, [ 29 | 'required' => false, 30 | 'constraints' => [ 31 | new Constraints\PositiveOrZero(), 32 | ], 33 | 'attr' => [ 34 | 'min' => 0, 35 | ], 36 | ]) 37 | ->add('idle_timeout', IntegerType::class, [ 38 | 'required' => false, 39 | 'constraints' => [ 40 | new Constraints\PositiveOrZero(), 41 | ], 42 | 'attr' => [ 43 | 'min' => 0, 44 | ], 45 | ]) 46 | ->add('logFile') 47 | ; 48 | } 49 | 50 | public function configureOptions(OptionsResolver $resolver): void 51 | { 52 | $resolver->setDefaults([ 53 | 'data_class' => ScheduledCommandInterface::class, 54 | ]); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Grid/FieldType/DatetimeFieldType.php: -------------------------------------------------------------------------------- 1 | 'scheduled_command_executed_at'])] 16 | final readonly class DatetimeFieldType implements FieldTypeInterface 17 | { 18 | public function __construct( 19 | #[Autowire('@sylius.grid.data_extractor.property_access')] 20 | private DataExtractorInterface $dataExtractor, 21 | private LocaleContextInterface $localeContext, 22 | ) { 23 | } 24 | 25 | /** 26 | * @inheritDoc 27 | */ 28 | public function render(Field $field, $data, array $options): string 29 | { 30 | $value = $this->dataExtractor->get($field, $data); 31 | if (!$value instanceof \DateTimeInterface) { 32 | return ''; 33 | } 34 | 35 | /** @var \IntlDateFormatter|null $fmt */ 36 | $fmt = \datefmt_create($this->localeContext->getLocaleCode(), $options['date_format'], $options['time_format']); 37 | 38 | if (!$fmt instanceof \IntlDateFormatter) { 39 | return ''; 40 | } 41 | 42 | /** @phpstan-ignore-next-line */ 43 | return $fmt->format($value) ?: ''; 44 | } 45 | 46 | public function configureOptions(OptionsResolver $resolver): void 47 | { 48 | $resolver->setDefaults([ 49 | 'format' => 'Y-m-d H:i:s', 50 | 'date_format' => \IntlDateFormatter::SHORT, 51 | 'time_format' => \IntlDateFormatter::SHORT, 52 | ]); 53 | $resolver->setAllowedTypes('format', 'string'); 54 | $resolver->setAllowedTypes('date_format', 'integer'); 55 | $resolver->setAllowedTypes('time_format', 'integer'); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Grid/FieldType/ScheduledCommandExecutionTimeType.php: -------------------------------------------------------------------------------- 1 | 'scheduled_command_execution_time'])] 13 | class ScheduledCommandExecutionTimeType implements FieldTypeInterface 14 | { 15 | private const HOUR_IN_SECONDES = 3600; 16 | 17 | private const MINUTE_IN_SECONDES = 60; 18 | 19 | /** 20 | * @inheritdoc 21 | */ 22 | public function render(Field $field, $scheduleCommand, array $options): string 23 | { 24 | if ($scheduleCommand->getExecutedAt() === null) { 25 | return ''; 26 | } 27 | 28 | $baseDateTime = $scheduleCommand->getCommandEndTime() ?? new \DateTime(); 29 | $time = $baseDateTime->getTimestamp() - $scheduleCommand->getExecutedAt()->getTimestamp(); 30 | 31 | if ($time > self::HOUR_IN_SECONDES) { 32 | $hours = (int) ($time / self::HOUR_IN_SECONDES) . 'h '; 33 | $minutes = (int) (($time % self::HOUR_IN_SECONDES) / self::MINUTE_IN_SECONDES) . 'm '; 34 | $seconds = (($time % self::HOUR_IN_SECONDES) % self::MINUTE_IN_SECONDES) . 's'; 35 | 36 | return $hours . $minutes . $seconds; 37 | } 38 | 39 | if ($time > self::MINUTE_IN_SECONDES) { 40 | $minutes = (int) ($time / self::MINUTE_IN_SECONDES) . 'm '; 41 | $seconds = (int) $time % self::MINUTE_IN_SECONDES . 's'; 42 | 43 | return $minutes . $seconds; 44 | } 45 | 46 | return (int) $time . 's'; 47 | } 48 | 49 | /** @inheritdoc */ 50 | public function configureOptions(OptionsResolver $resolver): void 51 | { 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Grid/FieldType/ScheduledCommandHumanReadableExpressionType.php: -------------------------------------------------------------------------------- 1 | 'scheduled_human_readable_expression'])] 15 | final readonly class ScheduledCommandHumanReadableExpressionType implements FieldTypeInterface 16 | { 17 | public function __construct(private Environment $twig, private HumanizerInterface $humanizer) 18 | { 19 | } 20 | 21 | /** 22 | * @inheritdoc 23 | */ 24 | public function render(Field $field, $scheduleCommand, array $options): string 25 | { 26 | return $this->twig->render( 27 | $options['template'], 28 | [ 29 | 'schedulerCommand' => $scheduleCommand, 30 | 'value' => $this->humanizer->humanize($scheduleCommand->getCronExpression()), 31 | ], 32 | ); 33 | } 34 | 35 | /** @inheritdoc */ 36 | public function configureOptions(OptionsResolver $resolver): void 37 | { 38 | $resolver->setRequired('template'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Grid/FieldType/ScheduledCommandStateType.php: -------------------------------------------------------------------------------- 1 | 'scheduled_command_state'])] 14 | final readonly class ScheduledCommandStateType implements FieldTypeInterface 15 | { 16 | public function __construct(private Environment $twig) 17 | { 18 | } 19 | 20 | /** 21 | * @inheritdoc 22 | */ 23 | public function render(Field $field, $scheduleCommand, array $options): string 24 | { 25 | return $this->twig->render($options['template'], [ 26 | 'schedulerCommand' => $scheduleCommand, 27 | ]); 28 | } 29 | 30 | /** @inheritdoc */ 31 | public function configureOptions(OptionsResolver $resolver): void 32 | { 33 | $resolver->setRequired('template'); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Grid/FieldType/ScheduledCommandUrlType.php: -------------------------------------------------------------------------------- 1 | 'scheduled_command_url'])] 16 | final readonly class ScheduledCommandUrlType implements FieldTypeInterface 17 | { 18 | public function __construct( 19 | private UrlGeneratorInterface $urlGenerator, 20 | private Environment $twig, 21 | #[Autowire(env: 'string:SYNOLIA_SCHEDULER_PLUGIN_LOGS_DIR')] 22 | private string $logsDir, 23 | ) { 24 | } 25 | 26 | /** 27 | * @inheritdoc 28 | */ 29 | public function render(Field $field, $scheduleCommand, array $options): string 30 | { 31 | $size = 0; 32 | 33 | $viewUrl = $this->urlGenerator->generate( 34 | 'sylius_admin_scheduler_view_log_file', 35 | [ 36 | 'command' => $scheduleCommand->getId(), 37 | ], 38 | ); 39 | 40 | $url = $this->urlGenerator->generate( 41 | 'download_schedule_log_file', 42 | [ 43 | 'command' => $scheduleCommand->getId(), 44 | ], 45 | ); 46 | 47 | $filePath = $this->logsDir . \DIRECTORY_SEPARATOR . $scheduleCommand->getLogFile(); 48 | if (\file_exists($filePath)) { 49 | $size = filesize($filePath); 50 | } 51 | 52 | return $this->twig->render( 53 | $options['template'], 54 | [ 55 | 'schedulerCommand' => $scheduleCommand, 56 | 'url' => $url, 57 | 'viewUrl' => $viewUrl, 58 | 'size' => $size, 59 | ], 60 | ); 61 | } 62 | 63 | /** @inheritdoc */ 64 | public function configureOptions(OptionsResolver $resolver): void 65 | { 66 | $resolver->setRequired('template'); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Humanizer/CronExpressionHumanizer.php: -------------------------------------------------------------------------------- 1 | getLocale(); 28 | 29 | try { 30 | return CronTranslator::translate($expression, $locale, $this->timeFormat24Hours); 31 | } catch (\Throwable) { 32 | return $expression; 33 | } 34 | } 35 | 36 | private function getLocale(): string 37 | { 38 | try { 39 | $locale = $this->localeContext->getLocaleCode(); 40 | 41 | if (\strlen($locale) === 2) { 42 | return $locale; 43 | } 44 | 45 | return mb_substr($locale, 0, 2); 46 | } catch (LocaleNotFoundException) { 47 | return 'en'; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Humanizer/HumanizerInterface.php: -------------------------------------------------------------------------------- 1 | getGrid(); 18 | 19 | if (!$grid->hasActionGroup('main')) { 20 | $grid->addActionGroup(ActionGroup::named('main')); 21 | } 22 | 23 | $actionGroup = $grid->getActionGroup('main'); 24 | 25 | if ($actionGroup->hasAction('go_to_commands')) { 26 | return; 27 | } 28 | 29 | $action = Action::fromNameAndType('go_to_commands', 'link'); 30 | $action->setLabel('synolia.menu.admin.main.configuration.scheduler_command'); 31 | $action->setOptions([ 32 | 'class' => 'btn-ghost-azure', 33 | 'icon' => 'tabler:list', 34 | 'link' => [ 35 | 'route' => 'synolia_admin_command_index', 36 | 'parameters' => [], 37 | ], 38 | ]); 39 | 40 | $actionGroup->addAction($action); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Listener/Grid/GoToHistoryButtonGridListener.php: -------------------------------------------------------------------------------- 1 | getGrid(); 18 | 19 | if (!$grid->hasActionGroup('main')) { 20 | $grid->addActionGroup(ActionGroup::named('main')); 21 | } 22 | 23 | $actionGroup = $grid->getActionGroup('main'); 24 | 25 | if ($actionGroup->hasAction('go_to_history')) { 26 | return; 27 | } 28 | 29 | $action = Action::fromNameAndType('go_to_history', 'link'); 30 | $action->setLabel('synolia.menu.admin.main.configuration.scheduler_command_history'); 31 | $action->setOptions([ 32 | 'class' => 'btn-ghost-azure', 33 | 'icon' => 'tabler:clock', 34 | 'link' => [ 35 | 'route' => 'synolia_admin_scheduled_command_index', 36 | 'parameters' => [], 37 | ], 38 | ]); 39 | 40 | $actionGroup->addAction($action); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Menu/AdminMenuListener.php: -------------------------------------------------------------------------------- 1 | getMenu(); 16 | $newSubmenu = $menu 17 | ->addChild('scheduler') 18 | ->setLabel('synolia.menu.admin.main.configuration.scheduler_command') 19 | ->setLabelAttribute('icon', 'tabler:list') 20 | ; 21 | $newSubmenu 22 | ->addChild('scheduler-command', [ 23 | 'route' => 'synolia_admin_command_index', 24 | ]) 25 | ->setAttribute('type', 'link') 26 | ->setLabel('synolia.menu.admin.main.configuration.scheduler_command') 27 | ; 28 | $newSubmenu 29 | ->addChild('scheduler-command-history', [ 30 | 'route' => 'synolia_admin_scheduled_command_index', 31 | ]) 32 | ->setAttribute('type', 'link') 33 | ->setLabel('synolia.menu.admin.main.configuration.scheduler_command_history') 34 | ; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Parser/CommandParser.php: -------------------------------------------------------------------------------- 1 | excludedNamespaces = $excludedNamespaces; 24 | } 25 | 26 | public function getCommands(): array 27 | { 28 | $application = new Application($this->kernel); 29 | 30 | $commandsList = []; 31 | $commands = $application->all(); 32 | foreach ($commands as $command) { 33 | $name = $command->getName() ?? ''; 34 | $namespace = \explode(':', $name)[0]; 35 | if (in_array($namespace, $this->excludedNamespaces, true)) { 36 | continue; 37 | } 38 | $commandsList[$namespace][$command->getName()] = $name; 39 | } 40 | 41 | return $commandsList; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Parser/CommandParserInterface.php: -------------------------------------------------------------------------------- 1 | scheduledCommandRepository->findBy([ 29 | 'command' => $command->getCommand(), 30 | 'state' => ScheduledCommandStateEnum::WAITING, 31 | ]); 32 | 33 | if (0 !== count($scheduledCommands)) { 34 | return array_shift($scheduledCommands); 35 | } 36 | 37 | /** @var ScheduledCommandInterface $scheduledCommand */ 38 | $scheduledCommand = $this->scheduledCommandFactory->createNew(); 39 | 40 | $scheduledCommand 41 | ->setName($command->getName()) 42 | ->setCommand($command->getCommand()) 43 | ->setArguments($command->getArguments()) 44 | ->setTimeout($command->getTimeout()) 45 | ->setIdleTimeout($command->getIdleTimeout()) 46 | ->setOwner($command) 47 | ; 48 | 49 | if (null !== $command->getLogFilePrefix() && '' !== $command->getLogFilePrefix()) { 50 | $scheduledCommand->setLogFile(\sprintf( 51 | '%s-%s-%s.log', 52 | $command->getLogFilePrefix(), 53 | (new \DateTime())->format('Y-m-d'), 54 | \uniqid(), 55 | )); 56 | } 57 | 58 | $this->entityManager->persist($scheduledCommand); 59 | $this->entityManager->flush(); 60 | 61 | $this->logger->info('Command has been planned for execution.', ['command_name' => $command->getName()]); 62 | 63 | return $scheduledCommand; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Planner/ScheduledCommandPlannerInterface.php: -------------------------------------------------------------------------------- 1 | timezone ?? date_default_timezone_get(); 29 | 30 | return new DateTimeImmutable(timezone: new DateTimeZone($timezone)); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Repository/CommandRepository.php: -------------------------------------------------------------------------------- 1 | $commands */ 18 | $commands = $this->findBy(['enabled' => true], ['priority' => 'DESC']); 19 | 20 | return $commands; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Repository/CommandRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | $command */ 18 | $command = $this->findBy(['state' => ScheduledCommandStateEnum::WAITING]); 19 | 20 | return $command; 21 | } 22 | 23 | public function findLastCreatedCommand(CommandInterface $command): ?ScheduledCommandInterface 24 | { 25 | try { 26 | return $this->createQueryBuilder('scheduled') 27 | ->where('scheduled.owner = :owner') 28 | ->setParameter('owner', $command->getId()) 29 | ->orderBy('scheduled.createdAt', 'DESC') 30 | ->setMaxResults(1) 31 | ->getQuery() 32 | ->getOneOrNullResult() 33 | ; 34 | } catch (NonUniqueResultException) { 35 | return null; 36 | } 37 | } 38 | 39 | public function findAllSinceXDaysWithState(\DateTimeInterface $dateTime, array $states): iterable 40 | { 41 | return $this->createQueryBuilder('scheduled') 42 | ->where('scheduled.state IN (:states)') 43 | ->andWhere('scheduled.createdAt < :createdAt') 44 | ->setParameter('states', $states) 45 | ->setParameter('createdAt', $dateTime->format('Y-m-d 00:00:00')) 46 | ->getQuery() 47 | ->getResult() 48 | ; 49 | } 50 | 51 | public function findAllHavingState(array $states): iterable 52 | { 53 | return $this->createQueryBuilder('scheduled') 54 | ->where('scheduled.state IN (:states)') 55 | ->setParameter('states', $states) 56 | ->getQuery() 57 | ->getResult() 58 | ; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Repository/ScheduledCommandRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | 'sylius.grid_field', 'type' => 'scheduled_command_url'])] 16 | class ScheduleCommandRunner implements ScheduleCommandRunnerInterface 17 | { 18 | public function __construct( 19 | private readonly ScheduledCommandRepositoryInterface $scheduledCommandRepository, 20 | private readonly EntityManagerInterface $entityManager, 21 | #[Autowire(env: 'string:SYNOLIA_SCHEDULER_PLUGIN_LOGS_DIR')] 22 | private readonly string $logsDir, 23 | #[Autowire(param: 'kernel.project_dir')] 24 | private readonly string $projectDir, 25 | #[Autowire(env: 'int:SYNOLIA_SCHEDULER_PLUGIN_PING_INTERVAL')] 26 | private readonly int $pingInterval = 60, 27 | #[Autowire(env: 'bool:SYNOLIA_SCHEDULER_PLUGIN_KEEP_ALIVE')] 28 | private readonly bool $keepConnectionAlive = false, 29 | ) { 30 | } 31 | 32 | public function runImmediately(string $scheduledCommandId): bool 33 | { 34 | /** @var ScheduledCommandInterface|null $scheduledCommand */ 35 | $scheduledCommand = $this->scheduledCommandRepository->find($scheduledCommandId); 36 | if (!$scheduledCommand instanceof ScheduledCommandInterface) { 37 | return false; 38 | } 39 | 40 | $process = Process::fromShellCommandline( 41 | $this->getCommandLine($scheduledCommand), 42 | $this->projectDir, 43 | ); 44 | 45 | $scheduledCommand->setExecutedAt(new \DateTime()); 46 | $process->setTimeout($scheduledCommand->getTimeout()); 47 | $process->setIdleTimeout($scheduledCommand->getIdleTimeout()); 48 | $this->startProcess($process); 49 | 50 | $result = $process->getExitCode(); 51 | 52 | if (null === $result) { 53 | $result = 0; 54 | } 55 | 56 | $scheduledCommand->setLastReturnCode($result); 57 | $scheduledCommand->setCommandEndTime(new \DateTime()); 58 | $this->entityManager->flush(); 59 | 60 | return true; 61 | } 62 | 63 | public function runFromCron(ScheduledCommandInterface $scheduledCommand): int 64 | { 65 | $process = Process::fromShellCommandline($this->getCommandLine($scheduledCommand)); 66 | $process->setTimeout($scheduledCommand->getTimeout()); 67 | $process->setIdleTimeout($scheduledCommand->getIdleTimeout()); 68 | 69 | try { 70 | $this->startProcess($process); 71 | } catch (ProcessTimedOutException) { 72 | } 73 | 74 | $result = $process->getExitCode(); 75 | $scheduledCommand->setCommandEndTime(new \DateTime()); 76 | 77 | if (null === $result) { 78 | $result = 0; 79 | } 80 | 81 | return $result; 82 | } 83 | 84 | private function startProcess(Process $process): void 85 | { 86 | if (!$this->keepConnectionAlive) { 87 | $process->run(); 88 | 89 | return; 90 | } 91 | 92 | $process->start(); 93 | while ($process->isRunning()) { 94 | $process->checkTimeout(); 95 | 96 | try { 97 | $this->entityManager->getConnection()->executeQuery($this->entityManager->getConnection()->getDatabasePlatform()->getDummySelectSQL()); 98 | } catch (\Doctrine\DBAL\Exception) { 99 | } 100 | 101 | for ($i = 0; $i < $this->pingInterval; ++$i) { 102 | if (!$process->isRunning()) { 103 | return; 104 | } 105 | \sleep(1); 106 | 107 | $process->checkTimeout(); 108 | } 109 | } 110 | } 111 | 112 | private function getLogOutput(ScheduledCommandInterface $scheduledCommand): ?string 113 | { 114 | if ($scheduledCommand->getLogFile() === null || $scheduledCommand->getLogFile() === '') { 115 | return null; 116 | } 117 | 118 | return $this->logsDir . \DIRECTORY_SEPARATOR . $scheduledCommand->getLogFile(); 119 | } 120 | 121 | private function getCommandLine(ScheduledCommandInterface $scheduledCommand): string 122 | { 123 | $commandLine = sprintf( 124 | '%s/bin/console %s %s', 125 | $this->projectDir, 126 | $scheduledCommand->getCommand(), 127 | $scheduledCommand->getArguments() ?? '', 128 | ); 129 | 130 | $logOutput = $this->getLogOutput($scheduledCommand); 131 | if (null !== $logOutput) { 132 | $commandLine = sprintf( 133 | '%s/bin/console %s %s >> %s 2>> %s', 134 | $this->projectDir, 135 | $scheduledCommand->getCommand(), 136 | $scheduledCommand->getArguments() ?? '', 137 | $logOutput, 138 | $logOutput, 139 | ); 140 | } 141 | 142 | return $commandLine; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/Runner/ScheduleCommandRunnerInterface.php: -------------------------------------------------------------------------------- 1 | formatBytes(...)), 18 | ]; 19 | } 20 | 21 | public function formatBytes(int $bytes): string 22 | { 23 | if (self::EMPTY_FILE_SIZE === $bytes) { 24 | return '0B'; 25 | } 26 | 27 | try { 28 | $number = floor(log($bytes, 1024)); 29 | 30 | return round($bytes / (1024 ** $number), [0, 2, 2, 2, 3][$number]) . ['B', 'kB', 'MB', 'GB', 'TB'][$number]; 31 | } catch (\Throwable) { 32 | return '0B'; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Validator/LogfilePrefixPropertyValidator.php: -------------------------------------------------------------------------------- 1 | buildViolation('synolia.forms.constraints.cannot_use_slash', [ 21 | '%%DIRECTORY_SEPARATOR%%' => DIRECTORY_SEPARATOR, 22 | ]) 23 | ->atPath('logFilePrefix') 24 | ->addViolation() 25 | ; 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Validator/LogfilePropertyValidator.php: -------------------------------------------------------------------------------- 1 | buildViolation('synolia.forms.constraints.cannot_use_slash', [ 21 | '%%DIRECTORY_SEPARATOR%%' => DIRECTORY_SEPARATOR, 22 | ]) 23 | ->atPath('logFile') 24 | ->addViolation() 25 | ; 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Voter/IsDueVoter.php: -------------------------------------------------------------------------------- 1 | checkers as $checker) { 24 | try { 25 | return $checker->isDue($command, $dateTime); 26 | } catch (IsNotDueException) { 27 | continue; 28 | } 29 | } 30 | 31 | return false; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Voter/IsDueVoterInterface.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /templates/Controller/js.html.twig: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /templates/Controller/modal.html.twig: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /templates/Controller/show.html.twig: -------------------------------------------------------------------------------- 1 | {% extends '@SyliusAdmin/shared/layout/base.html.twig' %} 2 | {% import '@SyliusAdmin/shared/helper/header.html.twig' as headers %} 3 | 4 | {% block title %}{{ 'synolia.ui.live_view_of_scheduled_command'|trans({'%scheduledCommandName%': scheduledCommand.name|e}) }} {{ parent() }}{% endblock %} 5 | 6 | {% block body %} 7 | {{ headers.default(1, 'synolia.ui.live_view_of_scheduled_command'|trans({'%scheduledCommandName%': scheduledCommand.name|e}), 'tabler:settings') }} 8 | 9 |
10 | 11 |
12 | 13 |
14 |         
15 |     
16 | 17 | {% hook 'synolia.admin.command.logs.stylesheets' %} 18 | 26 | {% hook 'synolia.admin.command.logs.javascripts' %} 27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /templates/Grid/Action/empty_log_file.html.twig: -------------------------------------------------------------------------------- 1 | {% set path = options.link.url|default(path(options.link.route, options.link.parameters)) %} 2 | 3 |
4 | 5 | 8 |
9 | -------------------------------------------------------------------------------- /templates/Grid/Action/empty_logs_file.html.twig: -------------------------------------------------------------------------------- 1 | {% set path = options.link.url|default(path(options.link.route)) %} 2 | 3 |
4 | 5 | 8 |
9 | -------------------------------------------------------------------------------- /templates/Grid/Action/execute_immediate.html.twig: -------------------------------------------------------------------------------- 1 | {% import '@SyliusAdmin/shared/helper/button.html.twig' as buttons %} 2 | 3 | {% set path = options.link.url|default(path(options.link.route, options.link.parameters)) %} 4 | 5 | {{ buttons.default({url: path, text: action.label|trans, icon: 'tabler:refresh', class: 'btn-primary'}) }} 6 | -------------------------------------------------------------------------------- /templates/Grid/Action/link.html.twig: -------------------------------------------------------------------------------- 1 | {% import '@SyliusAdmin/shared/helper/button.html.twig' as buttons %} 2 | 3 | {% set path = options.link.url|default(path(options.link.route, options.link.parameters)) %} 4 | 5 | {{ buttons.default({url: path, text: action.label|trans, icon: options.icon, class: options.class}) }} 6 | -------------------------------------------------------------------------------- /templates/Grid/Column/human_readable_expression.html.twig: -------------------------------------------------------------------------------- 1 | {% if schedulerCommand.cronExpression is not empty %} 2 | {{ value }} 3 | {% endif %} 4 | -------------------------------------------------------------------------------- /templates/Grid/Column/js.html.twig: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /templates/Grid/Column/log_file.html.twig: -------------------------------------------------------------------------------- 1 | {% if schedulerCommand.logFile is not null and schedulerCommand.logFile is not empty %} 2 |
3 | 4 | {{ ux_icon('tabler:play') }} 5 | {{ 'synolia.ui.live_view'|trans }} 6 | 7 | 8 | {{ ux_icon('tabler:download') }} 9 | {{ 'synolia.ui.download'|trans }} - {{ size|format_bytes }} 10 | 11 |
12 | {% endif %} 13 | -------------------------------------------------------------------------------- /templates/Grid/Column/scheduled_command_state.html.twig: -------------------------------------------------------------------------------- 1 | {% if schedulerCommand.state == 'waiting' %} 2 | 3 | {{ ux_icon('tabler:calendar') }} 4 | {{ ('synolia.ui.statuses.' ~ schedulerCommand.state)|trans }} 5 | 6 | {% endif %} 7 | {% if schedulerCommand.state == 'in_progress' %} 8 | 9 | {{ ux_icon('tabler:refresh') }} 10 | {{ ('synolia.ui.statuses.' ~ schedulerCommand.state)|trans }} 11 | 12 | {% endif %} 13 | {% if schedulerCommand.state == 'finished' %} 14 | 15 | {{ ux_icon('tabler:check') }} 16 | {{ ('synolia.ui.statuses.' ~ schedulerCommand.state)|trans }} 17 | 18 | {% endif %} 19 | {% if schedulerCommand.state == 'error' %} 20 | 21 | {{ ux_icon('tabler:bell') }} 22 | {{ ('synolia.ui.statuses.' ~ schedulerCommand.state)|trans }} 23 | 24 | {% endif %} 25 | {% if schedulerCommand.state == 'termination' %} 26 | 27 | {{ ux_icon('tabler:bell') }} 28 | {{ ('synolia.ui.statuses.' ~ schedulerCommand.state)|trans }} 29 | 30 | {% endif %} 31 | -------------------------------------------------------------------------------- /translations/messages.en.yml: -------------------------------------------------------------------------------- 1 | synolia: 2 | ui: 3 | commands: Scheduled commands 4 | scheduled_commands: Scheduled commands history 5 | scheduled_command: 6 | id: Id 7 | name: Name 8 | command: Command 9 | state: State 10 | cron_expression: Cron expression 11 | last_execution: Executed At 12 | last_return_code: Last return code 13 | log_file: Log file 14 | priority: Priority 15 | enabled: Enabled 16 | command_execution_time: Execution time 17 | execute_immediate: Execute immediately 18 | emtpy_logs: Empty logs 19 | bulk_empty_logs: The log files have been emptied. 20 | edit_command: Edit scheduled command 21 | new_scheduled_command: Create immediate scheduled command 22 | new_command: Create scheduled command 23 | launch_a_command: Launch a command 24 | log_file_undefined: Scheduled command has no defined log file. 25 | no_log_file_found: No log file found. 26 | error_emptying_log_file: There was an error while trying to empty the log file. 27 | log_file_successfully_emptied: Log file successfully emptied. 28 | scheduled_command_not_exists: Scheduled Command does not exists. 29 | live_view: Live view 30 | download: Download 31 | live_view_of_scheduled_command: 'Live view of scheduled command "%scheduledCommandName%"' 32 | does_not_exists_or_missing_log_file: 'Scheduler Command does not exists or has no log file.' 33 | statuses: 34 | waiting: Waiting 35 | in_progress: In Progress 36 | finished: Finished 37 | error: Error 38 | termination: Cancelled 39 | menu: 40 | admin: 41 | main: 42 | configuration: 43 | scheduler_command: Scheduled commands 44 | scheduler_command_history: Scheduled commands history 45 | sylius_plus: 46 | rbac: 47 | parent: 48 | commands: Scheduled commands 49 | scheduled_commands: Scheduled commands history 50 | -------------------------------------------------------------------------------- /translations/messages.fr.yml: -------------------------------------------------------------------------------- 1 | synolia: 2 | ui: 3 | commands: Commandes planifiées 4 | scheduled_commands: Historique des commandes planifiées 5 | scheduled_command: 6 | id: Id 7 | name: Nom 8 | command: Commande 9 | state: Etat 10 | cron_expression: Expression de Cron 11 | last_execution: Lancé à 12 | last_return_code: Dernier code de retour 13 | log_file: Fichier de log 14 | priority: Priorité 15 | enabled: Activé 16 | command_execution_time: Temps d'execution 17 | execute_immediate: Executer immédiatement 18 | emtpy_logs: Vider les logs 19 | bulk_empty_logs: Les fichiers de logs ont bien été vidé. 20 | edit_command: Editer la commande planifiée 21 | new_scheduled_command: Créer une commande planifiée immédiate 22 | new_command: Créer une commande planifiée 23 | launch_a_command: Lancer une commande 24 | live_view: Voir en direct 25 | download: Télécharger 26 | live_view_of_scheduled_command: 'Vue en direct de la commande "%scheduledCommandName%"' 27 | does_not_exists_or_missing_log_file: 'La commande n''existe pas ou n''a pas de fichier log.' 28 | statuses: 29 | waiting: En attente 30 | in_progress: En cours 31 | finished: Terminée 32 | error: En erreur 33 | termination: Annulée 34 | menu: 35 | admin: 36 | main: 37 | configuration: 38 | scheduler_command: Commandes planifiées 39 | scheduler_command_history: Historique des planifications 40 | sylius_plus: 41 | rbac: 42 | parent: 43 | commands: Commandes planifiées 44 | scheduled_commands: Historique des planifications 45 | -------------------------------------------------------------------------------- /translations/messages.nl.yml: -------------------------------------------------------------------------------- 1 | synolia: 2 | ui: 3 | commands: 'Geplande commando''s' 4 | scheduled_commands: Geplande commando geschiedenis 5 | scheduled_command: 6 | id: Id 7 | name: Naam 8 | command: Commando 9 | state: Status 10 | cron_expression: Cron expressie 11 | last_execution: Laatst uitgevoerd 12 | last_return_code: Laatste retourcode 13 | log_file: Log-bestand 14 | priority: Prioriteit 15 | enabled: Ingeschakeld 16 | command_execution_time: Uitvoer duur 17 | execute_immediate: Voer direct uit 18 | emtpy_logs: Logs legen 19 | bulk_empty_logs: De log-bestanden zijn geleegd. 20 | edit_command: Bewerk commando 21 | new_scheduled_command: Creëer direct een commando 22 | new_command: Creëer commando 23 | launch_a_command: Lanceer een commando 24 | log_file_undefined: Geplande commando heeft geen log-bestand gedefinieerd. 25 | no_log_file_found: Geen log-bestand gevonden. 26 | error_emptying_log_file: Er was een fout bij het leegmaken van het log-bestand. 27 | log_file_successfully_emptied: Log-bestand succesvol geleegd. 28 | scheduled_command_not_exists: Gepland commando bestaat niet. 29 | live_view: Live weergave 30 | download: Download 31 | live_view_of_scheduled_command: 'Live weergave van commando "%scheduledCommandName%"' 32 | does_not_exists_or_missing_log_file: 'Gepland commando bestaat niet of heeft geen log-bestand.' 33 | statuses: 34 | waiting: In afwachting 35 | in_progress: Bezig 36 | finished: Voltooid 37 | error: Fout 38 | menu: 39 | admin: 40 | main: 41 | configuration: 42 | scheduler_command: 'Geplande commando''s' 43 | scheduler_command_history: Geplande commando geschiedenis 44 | sylius_plus: 45 | rbac: 46 | parent: 47 | commands: 'Geplande commando''s' 48 | scheduled_commands: Geplande commando geschiedenis 49 | -------------------------------------------------------------------------------- /translations/validators.en.yaml: -------------------------------------------------------------------------------- 1 | synolia: 2 | forms: 3 | constraints: 4 | cannot_use_slash: You cannot use a "%%DIRECTORY_SEPARATOR%%" for security reason 5 | -------------------------------------------------------------------------------- /translations/validators.fr.yaml: -------------------------------------------------------------------------------- 1 | synolia: 2 | forms: 3 | constraints: 4 | cannot_use_slash: Vous ne pouvez pas utiliser de "%%DIRECTORY_SEPARATOR%%" pour des raisons de sécurité 5 | --------------------------------------------------------------------------------