├── CHANGELOG.md ├── Command ├── AddCommand.php ├── DisableCommand.php ├── ExecuteCommand.php ├── ListCommand.php ├── MonitorCommand.php ├── RemoveCommand.php ├── StartSchedulerCommand.php ├── StopSchedulerCommand.php ├── TestCommand.php └── UnlockCommand.php ├── Controller ├── AbstractBaseController.php ├── ApiController.php ├── DetailController.php └── ListController.php ├── DependencyInjection ├── Configuration.php └── DukecityCommandSchedulerExtension.php ├── DukecityCommandSchedulerBundle.php ├── Entity └── ScheduledCommand.php ├── Event ├── AbstractSchedulerCommandEvent.php ├── SchedulerCommandCreatedEvent.php ├── SchedulerCommandFailedEvent.php ├── SchedulerCommandPostExecutionEvent.php └── SchedulerCommandPreExecutionEvent.php ├── EventSubscriber └── SchedulerCommandSubscriber.php ├── Fixtures └── ORM │ └── LoadScheduledCommandData.php ├── Form └── Type │ ├── CommandChoiceType.php │ └── ScheduledCommandType.php ├── Notification └── CronMonitorNotification.php ├── README.md ├── Repository └── ScheduledCommandRepository.php ├── Resources ├── config │ ├── routing.php │ ├── services.php │ └── validation.php ├── doc │ ├── images │ │ ├── command-list.png │ │ ├── new-schedule.png │ │ └── scheduled-list.png │ ├── index.md │ └── integrations │ │ ├── easyadmin │ │ ├── ScheduledCommandCrudController.php │ │ └── index.md │ │ └── events │ │ ├── CustomSchedulerCommandSubscriber.php │ │ └── index.md ├── meta │ └── LICENCE ├── public │ ├── css │ │ ├── bootstrap-grid.css │ │ ├── bootstrap-grid.css.map │ │ ├── bootstrap-grid.min.css │ │ ├── bootstrap-grid.min.css.map │ │ ├── bootstrap-icons.css │ │ ├── bootstrap-reboot.css │ │ ├── bootstrap-reboot.css.map │ │ ├── bootstrap-reboot.min.css │ │ ├── bootstrap-reboot.min.css.map │ │ ├── bootstrap.css │ │ ├── bootstrap.css.map │ │ ├── bootstrap.min.css │ │ ├── bootstrap.min.css.map │ │ ├── custom.css │ │ ├── dataTables.bootstrap4.min.css │ │ ├── datatables.min.css │ │ ├── fonts │ │ │ ├── bootstrap-icons.woff │ │ │ └── bootstrap-icons.woff2 │ │ └── select2.min.css │ └── js │ │ ├── bootstrap-confirmation.js │ │ ├── bootstrap-confirmation.js.map │ │ ├── bootstrap-tooltip.js │ │ ├── bootstrap.bundle.js │ │ ├── bootstrap.bundle.js.map │ │ ├── bootstrap.bundle.min.js │ │ ├── bootstrap.bundle.min.js.map │ │ ├── bootstrap.js │ │ ├── bootstrap.js.map │ │ ├── bootstrap.min.js │ │ ├── bootstrap.min.js.map │ │ ├── dataTables.bootstrap4.min.js │ │ ├── datatables.min.js │ │ ├── jquery-3.6.0.min.js │ │ ├── jquery-3.6.0.slim.min.js │ │ ├── jquery-migrate-1.4.1.min.js │ │ ├── jquery-migrate-3.3.2.min.js │ │ ├── npm.js │ │ ├── popper.min.js │ │ ├── popper.min.js.map │ │ ├── select2.full.min.js │ │ └── select2.min.js ├── translations │ ├── DukecityCommandScheduler.de.xlf │ ├── DukecityCommandScheduler.en.xlf │ ├── DukecityCommandScheduler.es.xlf │ ├── DukecityCommandScheduler.fr.xlf │ ├── DukecityCommandScheduler.nl.xlf │ ├── DukecityCommandScheduler.pt-br.xlf │ ├── validators.de.xlf │ ├── validators.en.xlf │ ├── validators.es.xlf │ ├── validators.fr.xlf │ ├── validators.nl.xlf │ └── validators.pt-br.xlf └── views │ ├── Detail │ └── index.html.twig │ ├── List │ └── index.html.twig │ ├── Navbar │ └── navbar.html.twig │ └── layout.html.twig ├── Service ├── CommandParser.php └── CommandSchedulerExecution.php ├── UPGRADE.md ├── Validator └── Constraints │ ├── CronExpression.php │ └── CronExpressionValidator.php ├── composer.json └── phpstan.neon /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### New in Version 6: 2 | - Read Upgrade 3 | - Ensure that the log file reside in the log directory and finish with a ".log" extension 4 | - Add command `scheduler:disable` to disable one or all scheduled commands 5 | - Add support of Symfony 7 6 | - Drop support of Symfony 6 7 | - Drop support of php < 8.2 8 | - Bump minimum dependencies 9 | - rename branch `master` to `main` 10 | 11 | ### New in Version 5: 12 | - Add command to disable commands (by name or all). Useful for staging environments 13 | - Drop support of Symfony < 5.4 14 | 15 | ### New in Version 4: 16 | - API for all functions (in development) 17 | - Event-Handling (preExecution, postExecution). You can subscribe to this [Events](Resources/doc/integrations/events/index.md) 18 | - Monitoring: Optional Notifications with the [Symfony Notifier](https://symfony.com/doc/current/notifier.html) Component. Default: E-Mail 19 | - Refactored Execution of Commands to Services. You can use them now from other Services. 20 | - Handled error in Command Parsing. So there is no 500 Error while parsing commands. 21 | - You CLI-commands for add, remove and list scheduled commands 22 | - Improved UI of command-execution in cli 23 | 24 | ### Version 3: 25 | - An admin interface to add, edit, enable/disable or delete scheduled commands. 26 | - For each command, you define : 27 | - name 28 | - symfony console command (choice based on native `list` command) 29 | - cron expression (see [Cron format](http://en.wikipedia.org/wiki/Cron#Format) for informations) 30 | - output file (for `$output->write`) 31 | - priority 32 | - A new console command `scheduler:execute [--dump] [--no-output]` which will be the single entry point to all commands 33 | - Management of queuing and prioritization between tasks 34 | - Locking system, to stop scheduling a command that has returned an error 35 | - Monitoring with timeout or failed commands (Json URL and command with mailing) 36 | - Translated in french, english, german and spanish 37 | - An [EasyAdmin](https://github.com/EasyCorp/EasyAdminBundle) configuration template available [here](Resources/doc/integrations/easyadmin/index.md) 38 | - **Beta** - Handle commands with a daemon (unix only) if you don't want to use a cronjob -------------------------------------------------------------------------------- /Command/AddCommand.php: -------------------------------------------------------------------------------- 1 | em = $managerRegistry->getManager($managerName); 31 | 32 | parent::__construct(); 33 | } 34 | 35 | /** 36 | * {@inheritdoc} 37 | */ 38 | protected function configure(): void 39 | { 40 | $this->setDescription('Add a scheduled command') 41 | ->addArgument('name', InputArgument::REQUIRED, 'Name of the command') 42 | ->addArgument('cmd', InputArgument::REQUIRED, 'command') 43 | ->addArgument('arguments', InputArgument::REQUIRED, 'arguments') 44 | ->addArgument('cronExpression', InputArgument::REQUIRED, 'cronExpression') 45 | ->addArgument('logFile', InputArgument::OPTIONAL, 'logFile') 46 | ->addArgument('priority', InputArgument::OPTIONAL, 'priority', 0) 47 | ->addArgument('executeImmediately', InputArgument::OPTIONAL, 'executeImmediately', false) 48 | ->addArgument('disabled', InputArgument::OPTIONAL, 'disabled', false) 49 | # TODO Think about Update? 50 | #->addOption("--force", "-f", null, 'Force override', null) 51 | ; 52 | } 53 | 54 | /** 55 | * @throws \Exception 56 | */ 57 | protected function execute(InputInterface $input, OutputInterface $output): int 58 | { 59 | $io = new SymfonyStyle($input, $output); 60 | 61 | $commandName = (string) $input->getArgument('name'); 62 | $command = (string) $input->getArgument('cmd'); 63 | $arguments = (string) $input->getArgument('arguments'); 64 | $cronExpression = (string) $input->getArgument('cronExpression'); 65 | $priority = (int) $input->getArgument('priority'); 66 | $logFile = (string) $input->getArgument('logFile'); 67 | $executeImmediately = (bool) $input->getArgument('executeImmediately'); 68 | $disabled = (bool) $input->getArgument('disabled'); 69 | 70 | try { 71 | $cmd = $this->em->getRepository(ScheduledCommand::class) 72 | ->findOneBy(['name' => $commandName]); 73 | 74 | if (!$cmd) { 75 | $cmd = new ScheduledCommand(); 76 | $cmd->setName($commandName) 77 | ->setCommand($command) 78 | ->setArguments($arguments) 79 | ->setCronExpression($cronExpression) 80 | ->setPriority($priority) 81 | ->setLogFile($logFile) 82 | ->setExecuteImmediately($executeImmediately) 83 | ->setDisabled($disabled); 84 | 85 | $this->em->persist($cmd); 86 | $this->em->flush(); 87 | } else { 88 | $io->error(sprintf('Could not add the command %s (already exists)', $commandName)); 89 | 90 | return Command::FAILURE; 91 | } 92 | 93 | $io->success(sprintf('The Command %s is added successfully', $commandName)); 94 | 95 | return Command::SUCCESS; 96 | } catch (\Exception $e) { 97 | $io->error(sprintf('Could not add the command %s', $commandName)); 98 | #var_dump($e->getMessage()); 99 | 100 | return Command::FAILURE; 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Command/DisableCommand.php: -------------------------------------------------------------------------------- 1 | em = $managerRegistry->getManager($managerName); 32 | 33 | parent::__construct(); 34 | } 35 | 36 | /** 37 | * {@inheritdoc} 38 | */ 39 | protected function configure(): void 40 | { 41 | $this 42 | ->addArgument('name', InputArgument::OPTIONAL, 'Name of the command to disable') 43 | ->addOption('all', 'A', InputOption::VALUE_NONE, 'Disable all scheduled commands') 44 | ; 45 | } 46 | 47 | /** 48 | * Initialize parameters and services used in execute function. 49 | */ 50 | protected function initialize(InputInterface $input, OutputInterface $output): void 51 | { 52 | $this->disableAll = (bool) $input->getOption('all'); 53 | $this->scheduledCommandName = (string) $input->getArgument('name'); 54 | 55 | $this->io = new SymfonyStyle($input, $output); 56 | } 57 | 58 | /** 59 | * @throws \Exception 60 | */ 61 | protected function execute(InputInterface $input, OutputInterface $output): int 62 | { 63 | if (!$this->disableAll && empty($this->scheduledCommandName)) { 64 | $this->io->error('Either the name of a scheduled command or the --all option must be set.'); 65 | 66 | return Command::FAILURE; 67 | } 68 | 69 | $repository = $this->em->getRepository(ScheduledCommand::class); 70 | 71 | # disable ALL 72 | if ($this->disableAll) { 73 | // disable all commands 74 | $commands = $repository->findAll(); 75 | 76 | if ($commands) { 77 | foreach ($commands as $command) { 78 | 79 | // @see https://github.com/Dukecity/CommandSchedulerBundle/issues/46 80 | if ($command->getCommand() !== self::getDefaultName()) { 81 | $this->disable($command); 82 | } 83 | } 84 | } 85 | } else { 86 | # disable one 87 | $scheduledCommand = $repository->findOneBy(['name' => $this->scheduledCommandName]); 88 | 89 | if (null === $scheduledCommand) { 90 | $this->io->error( 91 | sprintf( 92 | 'Scheduled Command with name "%s" not found.', 93 | $this->scheduledCommandName 94 | ) 95 | ); 96 | 97 | return Command::FAILURE; 98 | } 99 | 100 | # only if it is not already disabled 101 | if(!$scheduledCommand->isDisabled()) 102 | { 103 | $this->disable($scheduledCommand); 104 | } 105 | } 106 | 107 | $this->em->flush(); 108 | 109 | return Command::SUCCESS; 110 | } 111 | 112 | /** 113 | * @throws \Exception 114 | */ 115 | protected function disable(ScheduledCommand $command): void 116 | { 117 | $command->setDisabled(true); 118 | 119 | $this->io->success(sprintf('Scheduled Command "%s" has been disabled.', $command->getName())); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Command/ExecuteCommand.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | #[AsCommand(name: 'scheduler:execute', description: 'Execute scheduled commands')] 31 | class ExecuteCommand extends Command 32 | { 33 | use LockableTrait; 34 | 35 | //private ObjectManager | EntityManager $em; 36 | private ObjectManager $em; 37 | private string $dumpMode; 38 | private ?int $commandsVerbosity = null; 39 | private OutputInterface $output; 40 | private InputInterface $input; 41 | private string $env; 42 | 43 | public function __construct( 44 | private CommandSchedulerExecution $commandSchedulerExecution, 45 | private EventDispatcherInterface $eventDispatcher, 46 | ManagerRegistry $managerRegistry, 47 | string $managerName, 48 | private string | bool $logPath 49 | ) { 50 | $this->em = $managerRegistry->getManager($managerName); 51 | 52 | // If logpath is not set to false, append the directory separator to it 53 | if (false !== $this->logPath) { 54 | $this->logPath = rtrim($this->logPath, '/\\').DIRECTORY_SEPARATOR; 55 | } 56 | 57 | parent::__construct(); 58 | } 59 | 60 | /** 61 | * {@inheritdoc} 62 | */ 63 | protected function configure(): void 64 | { 65 | $this->setDescription('Execute scheduled commands') 66 | ->addOption('dump', null, InputOption::VALUE_NONE, 'Display next execution') 67 | ->addOption('no-output', null, InputOption::VALUE_NONE, 'Disable output message from scheduler') 68 | ->setHelp(<<<'HELP' 69 | The %command.name% is the entry point to execute all scheduled command: 70 | 71 | You can list the commands with last and next exceution time with 72 | php bin/console scheduler:list 73 | 74 | HELP 75 | ); 76 | } 77 | 78 | /** 79 | * Initialize parameters and services used in execute function. 80 | */ 81 | protected function initialize(InputInterface $input, OutputInterface $output): void 82 | { 83 | $this->output = $output; 84 | $this->input = $input; 85 | 86 | $this->dumpMode = (string) $this->input->getOption('dump'); 87 | 88 | try{ 89 | $this->env = $this->input->getOption('env'); 90 | } 91 | catch (\Exception) 92 | { 93 | $this->env = "test"; 94 | } 95 | 96 | // Store the original verbosity before apply the quiet parameter 97 | $this->commandsVerbosity = $this->output->getVerbosity(); 98 | 99 | if (true === $this->input->getOption('no-output')) { 100 | $this->output->setVerbosity(OutputInterface::VERBOSITY_QUIET); 101 | } 102 | } 103 | 104 | /** 105 | * {@inheritdoc} 106 | * 107 | * @throws \Exception 108 | * @throws ExceptionInterface 109 | */ 110 | protected function execute(InputInterface $input, OutputInterface $output): int 111 | { 112 | /* 113 | * Be sure that there are no overlapping Execution of commands. 114 | * The command is released at the end of this function 115 | * @see https://symfony.com/doc/current/console/lockable_trait.html 116 | */ 117 | if (!$this->lock()) { 118 | $this->output->writeln('The command is already running in another process.'); 119 | 120 | return Command::SUCCESS; 121 | } 122 | 123 | # For Unittests ;( 124 | if(is_a($this->output, ConsoleOutput::class)) 125 | { 126 | $sectionListing = $this->output->section(); 127 | $sectionProgressbar = $this->output->section(); 128 | $io = new SymfonyStyle($this->input, $sectionListing); 129 | } 130 | else 131 | { 132 | $sectionProgressbar = $this->output; 133 | $io = new SymfonyStyle($this->input, $this->output); 134 | #$this->env="test"; 135 | } 136 | 137 | 138 | // Before continue, we check that the "log_path" is valid and writable (except for gaufrette) 139 | if (false !== $this->logPath && 140 | !str_starts_with($this->logPath, 'gaufrette:') && 141 | !is_writable($this->logPath) 142 | ) { 143 | $io->error( 144 | $this->logPath.' not found or not writable. Check `log_path` in your config.yml' 145 | ); 146 | 147 | return Command::FAILURE; 148 | } 149 | 150 | $commandsToExecute = $this->em->getRepository(ScheduledCommand::class) 151 | ->findCommandsToExecute(); 152 | $amountCommands = count($commandsToExecute); 153 | 154 | 155 | 156 | $io->title('Start : '.($this->dumpMode ? 'Dump' : 'Execute').' of '.$amountCommands.' scheduled command(s)'); 157 | 158 | 159 | if (is_iterable($commandsToExecute) && $amountCommands >= 1) 160 | { 161 | # dry-run ? 162 | if ($this->input->getOption('dump')) 163 | { 164 | foreach ($commandsToExecute as $command) 165 | { 166 | $io->info($command->getName().': '.$command->getCommand().' '.$command->getArguments()); 167 | } 168 | } 169 | else 170 | { 171 | # Execute 172 | #$sectionProgressbar = $this->output->section(); 173 | $progress = new ProgressBar($sectionProgressbar); 174 | $progress->setMessage('Start'); 175 | $progress->start($amountCommands); 176 | 177 | foreach ($commandsToExecute as $command) 178 | { 179 | $progress->setMessage('Start Execution of '.$command->getCommand().' '.$command->getArguments()); 180 | $io->comment('Start Execution of '.$command->getCommand().' '.$command->getArguments()); 181 | 182 | $result = $this->commandSchedulerExecution->executeCommand($command, $this->env, $this->commandsVerbosity); 183 | 184 | if($result===0) 185 | {$io->success($command->getName().': '.$command->getCommand().' '.$command->getArguments());} 186 | else 187 | {$io->error($command->getName().': ERROR '.$result.': '.$command->getCommand().' '.$command->getArguments());} 188 | 189 | $progress->advance(); 190 | } 191 | 192 | $progress->finish(); 193 | 194 | if(method_exists($sectionProgressbar, 'clear')) 195 | {$sectionProgressbar->clear();} 196 | 197 | $io->section('Finished Executions'); 198 | 199 | }} 200 | else { 201 | $io->success('Nothing to do.'); 202 | } 203 | 204 | 205 | $this->release(); 206 | 207 | return Command::SUCCESS; 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /Command/ListCommand.php: -------------------------------------------------------------------------------- 1 | em = $managerRegistry->getManager($managerName); 29 | parent::__construct(); 30 | } 31 | 32 | /** 33 | * {@inheritdoc} 34 | */ 35 | protected function configure(): void 36 | { 37 | $this->setDescription('List scheduled commands') 38 | ->setHelp('This class is for listing all active commands.'); 39 | } 40 | 41 | /** 42 | * @throws \Exception 43 | */ 44 | protected function execute(InputInterface $input, OutputInterface $output): int 45 | { 46 | $commands = $this->em->getRepository(ScheduledCommand::class)->findAll(); 47 | 48 | $table = new Table($output); 49 | $table->setStyle('box'); 50 | $table->setHeaders(['Name', 'Command', 'Arguments', 'Locked', 'LastExecution', 'NextExecution']); 51 | 52 | foreach ($commands as $command) 53 | { 54 | $lockedInfo = match ($command->getLocked()) 55 | { 56 | true => 'LOCKED', 57 | default => 'NO' 58 | }; 59 | 60 | $lastReturnName = match ($command->getLastReturnCode()) 61 | { 62 | '', false, null, 0 => ''.$command->getName().'', 63 | default => ''.$command->getName().'' 64 | }; 65 | 66 | if($nextRunDate = $command->getNextRunDate()) 67 | {$nextRunDateText = $this->dateTimeFormatter->formatDiff($nextRunDate);} 68 | else {$nextRunDateText = "";} 69 | 70 | if($lastRunDate = $command->getLastExecution()) 71 | {$lastRunDateText = $this->dateTimeFormatter->formatDiff($lastRunDate);} 72 | else {$lastRunDateText = "";} 73 | 74 | $table->addRow([ 75 | $lastReturnName, 76 | $command->getCommand(), 77 | $command->getArguments(), 78 | $lockedInfo, 79 | $lastRunDateText, 80 | $nextRunDateText 81 | // $command->getNextRunDateForHumans(), 82 | ]); 83 | } 84 | 85 | $table->render(); 86 | 87 | return Command::SUCCESS; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Command/MonitorCommand.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | #[AsCommand(name: 'scheduler:monitor', description: 'Monitor scheduled commands')] 28 | class MonitorCommand extends Command 29 | { 30 | private ObjectManager $em; 31 | 32 | //private ParameterBagInterface $params; 33 | 34 | /** 35 | * @param string[] $receiver 36 | */ 37 | public function __construct( 38 | private EventDispatcherInterface $eventDispatcher, 39 | ManagerRegistry $managerRegistry, 40 | private readonly DateTimeFormatter $dateTimeFormatter, 41 | string $managerName, 42 | private int | bool $lockTimeout, 43 | private array $receiver, 44 | private string $mailSubject, 45 | private bool $sendMailIfNoError = false 46 | ) { 47 | $this->em = $managerRegistry->getManager($managerName); 48 | parent::__construct(); 49 | } 50 | 51 | /** 52 | * {@inheritdoc} 53 | */ 54 | protected function configure(): void 55 | { 56 | $this->setDescription('Monitor scheduled commands') 57 | ->addOption('dump', null, InputOption::VALUE_NONE, 'Display result instead of send mail') 58 | ->setHelp(<<<'HELP' 59 | The %command.name% is reporting failed and timedout scheduled commands: 60 | 61 | php %command.full_name% 62 | 63 | By default the command sends the info via symfony messanger to the configured recipients. 64 | You can just print the infos on the console via the --dump option: 65 | 66 | php %command.full_name% --dump 67 | 68 | HELP); 69 | } 70 | 71 | /** 72 | * @throws \Exception 73 | */ 74 | protected function execute(InputInterface $input, OutputInterface $output): int 75 | { 76 | // If not in dump mode and none receiver is set, exit. 77 | $dumpMode = (bool) $input->getOption('dump'); 78 | if (!$dumpMode && 0 === count($this->receiver)) { 79 | $output->writeln('Please add receiver in configuration. Or use --dump option'); 80 | 81 | return Command::FAILURE; 82 | } 83 | 84 | // Fist, get all failed or potential timeout 85 | $failedCommands = $this->em->getRepository(ScheduledCommand::class) 86 | ->findFailedAndTimeoutCommands($this->lockTimeout); 87 | //->findAll(); // for notification testing 88 | 89 | // Commands in error 90 | if (count($failedCommands) > 0) { 91 | // if --dump option, don't send mail 92 | if ($dumpMode) { 93 | $this->dump($output, $failedCommands); 94 | } else { 95 | $this->eventDispatcher->dispatch(new SchedulerCommandFailedEvent($failedCommands)); 96 | } 97 | } elseif ($dumpMode) { 98 | $output->writeln('No errors found.'); 99 | } /*elseif ($this->params->get('sendMailIfNoError')) { 100 | $this->sendMails('No errors found.'); 101 | }*/ 102 | 103 | return Command::SUCCESS; 104 | } 105 | 106 | /** 107 | * Print a table of locked Commands to console. 108 | * 109 | * @param ScheduledCommand[] $failedCommands 110 | * @throws \Exception 111 | */ 112 | private function dump(OutputInterface $output, array $failedCommands): void 113 | { 114 | $table = new Table($output); 115 | $table->setStyle('box'); 116 | $table->setHeaders(['Name', 'LastReturnCode', 'Locked', 'LastExecution', 'NextExecution']); 117 | 118 | foreach ($failedCommands as $command) { 119 | $lockedInfo = match ($command->getLocked()) { 120 | true => 'LOCKED', 121 | default => '' 122 | }; 123 | 124 | $lastReturnInfo = match ($command->getLastReturnCode()) { 125 | '', false, null => '', 126 | 0 => '0 (success)', 127 | // no break 128 | default => ''.$command->getLastReturnCode().' (error)' 129 | }; 130 | 131 | 132 | $lastRunDate = $command->getLastExecution(); 133 | if($lastRunDate) 134 | { 135 | $lastRunDateText = $lastRunDate->format('Y-m-d H:i').' (' 136 | .$this->dateTimeFormatter->formatDiff($command->getLastExecution()).')'; 137 | } 138 | else { 139 | $lastRunDateText = ''; 140 | } 141 | 142 | $nextRunDate = $command->getNextRunDate(); 143 | if($nextRunDate) 144 | { 145 | $nextRunDateText = $nextRunDate->format('Y-m-d H:i').' (' 146 | .$this->dateTimeFormatter->formatDiff($command->getLastExecution()).')'; 147 | } 148 | else { 149 | $nextRunDateText = ''; 150 | } 151 | 152 | $table->addRow([ 153 | $command->getName(), 154 | $lastReturnInfo, 155 | $lockedInfo, 156 | $lastRunDateText, 157 | $nextRunDateText 158 | ]); 159 | } 160 | 161 | $table->render(); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /Command/RemoveCommand.php: -------------------------------------------------------------------------------- 1 | em = $managerRegistry->getManager($managerName); 32 | 33 | parent::__construct(); 34 | } 35 | 36 | /** 37 | * {@inheritdoc} 38 | */ 39 | protected function configure(): void 40 | { 41 | $this->setDescription('Remove a scheduled command. Hard delete from Database') 42 | ->addArgument('name', InputArgument::REQUIRED, 'Name of the command to remove') 43 | ->setHelp(<<<'HELP' 44 | The %command.name% command deletes scheduled command from the database: 45 | 46 | php %command.full_name% name 47 | 48 | If you omit the argument, the command will ask you to provide the missing value: 49 | 50 | php %command.full_name% 51 | 52 | You can list all available commands with 53 | 54 | php console scheduler:list 55 | 56 | HELP 57 | ) 58 | ; 59 | } 60 | 61 | protected function initialize(InputInterface $input, OutputInterface $output): void 62 | { 63 | $this->io = new SymfonyStyle($input, $output); 64 | } 65 | 66 | protected function interact(InputInterface $input, OutputInterface $output): void 67 | { 68 | if (null !== $input->getArgument('name')) { 69 | return; 70 | } 71 | 72 | $this->io->title('Delete Scheduled Command Interactive Wizard'); 73 | $this->io->text([ 74 | 'If you prefer to not use this interactive wizard, provide the', 75 | 'arguments required by this command as follows:', 76 | '', 77 | ' $ php bin/console '.self::getDefaultName().' name', 78 | '', 79 | 'Now we\'ll ask you for the value of all the missing command arguments.', 80 | '', 81 | ]); 82 | 83 | $name = $this->io->ask('Name of Scheduled Command'); 84 | $input->setArgument('name', $name); 85 | } 86 | 87 | /** 88 | * @throws \Exception 89 | */ 90 | protected function execute(InputInterface $input, OutputInterface $output): int 91 | { 92 | $io = new SymfonyStyle($input, $output); 93 | 94 | $commandName = (string) $input->getArgument('name'); 95 | 96 | try { 97 | $command = $this->em->getRepository(ScheduledCommand::class)->findOneBy( 98 | ['name' => $commandName] 99 | ); 100 | 101 | if(!$command) 102 | {throw new \InvalidArgumentException('Command with that name not found');} 103 | 104 | $this->em->remove($command); 105 | $this->em->flush(); 106 | 107 | $io->success(sprintf('The Command %s is deleted successfully', $commandName)); 108 | 109 | return Command::SUCCESS; 110 | } catch (\Exception) { 111 | $io->error(sprintf('Could not find/delete the command %s', $commandName)); 112 | 113 | return Command::FAILURE; 114 | } 115 | } 116 | 117 | /** 118 | * @return string[] 119 | */ 120 | public function getCommandNames(): array 121 | { 122 | $return = []; 123 | $commands = $this->em->getRepository(ScheduledCommand::class)->findAll(); 124 | foreach ($commands as $command){ 125 | $return[] = $command->getName(); 126 | } 127 | 128 | return $return; 129 | } 130 | 131 | public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void 132 | { 133 | if ($input->mustSuggestArgumentValuesFor('name')) { 134 | $suggestions->suggestValues($this->getCommandNames()); 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /Command/StartSchedulerCommand.php: -------------------------------------------------------------------------------- 1 | . 21 | * 22 | * Adaption to CommandSchedulerBundle by Christoph Singer 23 | */ 24 | #[AsCommand(name: 'scheduler:start', description: 'Starts command scheduler')] 25 | class StartSchedulerCommand extends Command 26 | { 27 | const PID_FILE = '.cron-pid'; 28 | 29 | private EntityManagerInterface $em; 30 | 31 | public function __construct( 32 | ManagerRegistry $managerRegistry, 33 | string $managerName, 34 | ) { 35 | $this->em = $managerRegistry->getManager($managerName); 36 | 37 | parent::__construct(); 38 | } 39 | 40 | /** 41 | * {@inheritdoc} 42 | */ 43 | protected function configure(): void 44 | { 45 | $this->setDescription('Starts command scheduler') 46 | ->addOption('blocking', 'b', InputOption::VALUE_NONE, 'Run in blocking mode.') 47 | ->setHelp(<<<'HELP' 48 | The %command.name% is for running the manual command scheduler: 49 | 50 | You can enable the blocking mode with 51 | php %command.full_name% -b, --blocking 52 | 53 | Deamon (Beta) : If you don't want to set up a cron job, you can use 54 | scheduler:start and scheduler:stop commands. 55 | This commands manage a deamon process that will call scheduler:execute every minute. 56 | It require the pcntlphp extension. 57 | Note that with this mode, if a command with an error, it will stop all the scheduler. 58 | 59 | Note : Each command is locked just before his execution (and unlocked after). 60 | This system avoid to have simultaneous process for the same command. Thus, 61 | if an non-catchable error occurs, the command won't be executed again unless the problem 62 | is solved and the task is unlocked manually 63 | 64 | HELP 65 | ); 66 | } 67 | 68 | /** 69 | * {@inheritdoc} 70 | */ 71 | protected function execute(InputInterface $input, OutputInterface $output): int 72 | { 73 | if ($input->getOption('blocking')) { 74 | $output->writeln(sprintf('%s', 'Starting command scheduler in blocking mode. Press CTRL+C to cancel')); 75 | $this->scheduler($output->isVerbose() ? $output : new NullOutput(), null); 76 | 77 | return Command::SUCCESS; 78 | } 79 | 80 | if (!extension_loaded('pcntl')) { 81 | throw new \RuntimeException('This command needs the pcntl extension to run.'); 82 | } 83 | 84 | $pidFile = sys_get_temp_dir().DIRECTORY_SEPARATOR.self::PID_FILE; 85 | 86 | if (-1 === $pid = pcntl_fork()) { 87 | throw new \RuntimeException('Unable to start the cron process.'); 88 | } elseif (0 !== $pid) { 89 | if (false === file_put_contents($pidFile, $pid)) { 90 | throw new \RuntimeException('Unable to create process file.'); 91 | } 92 | 93 | $output->writeln(sprintf('%s', 'Command scheduler started in non-blocking mode...')); 94 | 95 | return Command::SUCCESS; 96 | } 97 | 98 | if (-1 === posix_setsid()) { 99 | throw new \RuntimeException('Unable to set the child process as session leader.'); 100 | } 101 | 102 | $this->scheduler(new NullOutput(), $pidFile); 103 | 104 | return Command::SUCCESS; 105 | } 106 | 107 | /** 108 | * @throws \Symfony\Component\Console\Exception\ExceptionInterface 109 | */ 110 | private function scheduler(OutputInterface $output, ?string $pidFile): void 111 | { 112 | $input = new ArrayInput([]); 113 | 114 | $console = $this->getApplication(); 115 | $command = $console->find('scheduler:execute'); 116 | 117 | while (true) { 118 | $now = (int) microtime(false); 119 | usleep((int) ((60 - ($now % 60) + (int) $now - $now) * 1_000_000.0)); 120 | 121 | if (null !== $pidFile && !file_exists($pidFile)) { 122 | break; 123 | } 124 | 125 | $command->run($input, $output); 126 | $this->em->clear(ScheduledCommand::class); 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Command/StopSchedulerCommand.php: -------------------------------------------------------------------------------- 1 | . 16 | * 17 | * Adaption to CommandSchedulerBundle by Christoph Singer 18 | */ 19 | #[AsCommand(name: 'scheduler:stop', description: 'Stops command scheduler')] 20 | class StopSchedulerCommand extends Command 21 | { 22 | /** 23 | * {@inheritdoc} 24 | */ 25 | protected function configure(): void 26 | { 27 | $this->setHelp(<<<'HELP' 28 | The %command.name% stopps the manual scheduler which was startet via scheduler:start 29 | 30 | HELP 31 | ); 32 | } 33 | 34 | /** 35 | * {@inheritdoc} 36 | */ 37 | protected function execute(InputInterface $input, OutputInterface $output): int 38 | { 39 | $pidFile = sys_get_temp_dir().DIRECTORY_SEPARATOR.StartSchedulerCommand::PID_FILE; 40 | if (!file_exists($pidFile)) { 41 | $output->writeln(sprintf('%s', 'Command scheduler is not running')); 42 | 43 | return Command::SUCCESS; 44 | } 45 | if (!extension_loaded('pcntl')) { 46 | throw new \RuntimeException('This command needs the pcntl extension to run.'); 47 | } 48 | if (!posix_kill(file_get_contents($pidFile), SIGINT)) { 49 | if (!unlink($pidFile)) { 50 | throw new \RuntimeException('Unable to stop scheduler.'); 51 | } 52 | $output->writeln(sprintf('%s', 'Unable to kill command scheduler process. Scheduler will be stopped before the next run.')); 53 | 54 | return Command::SUCCESS; 55 | } 56 | unlink($pidFile); 57 | $output->writeln(sprintf('%s', 'Command scheduler is stopped.')); 58 | 59 | return Command::SUCCESS; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Command/TestCommand.php: -------------------------------------------------------------------------------- 1 | addArgument('runtime', InputArgument::OPTIONAL, 'Runtime in Seconds', 10) 29 | ->addArgument('returnFail', InputArgument::OPTIONAL, 'Fake Fail Return', false) 30 | ->setHidden(true) 31 | ; 32 | } 33 | 34 | /** 35 | * Initialize parameters and services used in execute function. 36 | */ 37 | protected function initialize(InputInterface $input, OutputInterface $output): void 38 | { 39 | $this->runtime = (int) ($input->getArgument('runtime') ?? 10); 40 | $this->returnFail = (bool) ($input->getArgument('returnFail') ?? false); 41 | 42 | $this->io = new SymfonyStyle($input, $output); 43 | } 44 | 45 | /** 46 | * @throws \Exception 47 | */ 48 | protected function execute(InputInterface $input, OutputInterface $output): int 49 | { 50 | $this->io->info('Start the process for '.$this->runtime.' seconds'); 51 | 52 | $i = 0; 53 | while ($i < $this->runtime) { 54 | ++$i; 55 | sleep(1); 56 | $this->io->info('Output after '.$i.' Seconds'); 57 | } 58 | 59 | # fake fail? 60 | if ($this->returnFail) 61 | { 62 | $this->io->info('Response-Code is forced to '.Command::FAILURE); 63 | return Command::FAILURE; 64 | } 65 | 66 | return Command::SUCCESS; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Command/UnlockCommand.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | #[AsCommand(name: 'scheduler:unlock', description: 'Unlock one or all scheduled commands that have surpassed the lock timeout.')] 24 | class UnlockCommand extends Command 25 | { 26 | private ObjectManager $em; 27 | public const DEFAULT_LOCK_TIME = 3600; // 1 hour 28 | private SymfonyStyle $io; 29 | 30 | private bool $unlockAll; 31 | private string | null $scheduledCommandName = null; 32 | 33 | /** 34 | * UnlockCommand constructor. 35 | * 36 | * @param ManagerRegistry $managerRegistry 37 | * @param string $managerName 38 | * @param int $lockTimeout Number of seconds after a command is considered as timeout 39 | */ 40 | public function __construct( 41 | ManagerRegistry $managerRegistry, 42 | string $managerName, 43 | private int $lockTimeout = self::DEFAULT_LOCK_TIME 44 | ) { 45 | $this->em = $managerRegistry->getManager($managerName); 46 | 47 | parent::__construct(); 48 | } 49 | 50 | /** 51 | * {@inheritdoc} 52 | */ 53 | protected function configure(): void 54 | { 55 | $this 56 | ->addArgument('name', InputArgument::OPTIONAL, 'Name of the command to unlock') 57 | ->addOption('all', 'A', InputOption::VALUE_NONE, 'Unlock all scheduled commands') 58 | ->addOption( 59 | 'lock-timeout', 60 | null, 61 | InputOption::VALUE_REQUIRED, 62 | 'Use this lock timeout value instead of the configured one (in seconds, optional)' 63 | ); 64 | } 65 | 66 | /** 67 | * Initialize parameters and services used in execute function. 68 | */ 69 | protected function initialize(InputInterface $input, OutputInterface $output): void 70 | { 71 | $this->unlockAll = (bool) $input->getOption('all'); 72 | $this->scheduledCommandName = (string) $input->getArgument('name'); 73 | 74 | $this->lockTimeout = (int)$input->getOption('lock-timeout'); 75 | 76 | if (0 === $this->lockTimeout) { 77 | $this->lockTimeout = self::DEFAULT_LOCK_TIME; 78 | } 79 | 80 | $this->io = new SymfonyStyle($input, $output); 81 | } 82 | 83 | /** 84 | * @throws \Exception 85 | */ 86 | protected function execute(InputInterface $input, OutputInterface $output): int 87 | { 88 | if (!$this->unlockAll && empty($this->scheduledCommandName)) { 89 | $this->io->error('Either the name of a scheduled command or the --all option must be set.'. 90 | PHP_EOL.'List all locked Commands: php console scheduler:monitor --dump'); 91 | 92 | return Command::FAILURE; 93 | } 94 | 95 | $repository = $this->em->getRepository(ScheduledCommand::class); 96 | 97 | if ($this->unlockAll) { 98 | // Unlock all locked commands 99 | $failedCommands = $repository->findLockedCommand(); 100 | 101 | if ($failedCommands) { 102 | foreach ($failedCommands as $failedCommand) { 103 | 104 | // @see https://github.com/Dukecity/CommandSchedulerBundle/issues/46 105 | if ($failedCommand->getCommand() !== self::getDefaultName()) { 106 | $this->unlock($failedCommand); 107 | } 108 | } 109 | } 110 | } else { 111 | $scheduledCommand = $repository->findOneBy(['name' => $this->scheduledCommandName, 'disabled' => false]); 112 | if (null === $scheduledCommand) { 113 | $this->io->error( 114 | sprintf( 115 | 'Scheduled Command with name "%s" not found or is disabled.', 116 | $this->scheduledCommandName 117 | ) 118 | ); 119 | 120 | return Command::FAILURE; 121 | } 122 | 123 | $this->unlock($scheduledCommand); 124 | } 125 | 126 | $this->em->flush(); 127 | 128 | return Command::SUCCESS; 129 | } 130 | 131 | /** 132 | * @throws \Exception 133 | */ 134 | protected function unlock(ScheduledCommand $command): bool 135 | { 136 | if (!$command->isLocked()) { 137 | $this->io->warning(sprintf('Skipping: Scheduled Command "%s" is not locked.', $command->getName())); 138 | 139 | return false; 140 | } 141 | 142 | if ($this->lockTimeout && 143 | null !== $command->getLastExecution() && 144 | $command->getLastExecution() >= (new \DateTime())->sub( 145 | new \DateInterval(sprintf('PT%dS', $this->lockTimeout)) 146 | ) 147 | ) { 148 | $this->io->error( 149 | sprintf('Skipping: Timeout for scheduled Command "%s" has not run out.', $command->getName()) 150 | ); 151 | 152 | return false; 153 | } 154 | 155 | $command->setLocked(false); 156 | $this->io->success(sprintf('Scheduled Command "%s" has been unlocked.', $command->getName())); 157 | 158 | return true; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /Controller/AbstractBaseController.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | abstract class AbstractBaseController extends AbstractController 16 | { 17 | private string $managerName; 18 | private ManagerRegistry $managerRegistry; 19 | 20 | public function setManagerRegistry(ManagerRegistry $managerRegistry): void 21 | { 22 | $this->managerRegistry = $managerRegistry; 23 | } 24 | 25 | protected ContractsTranslatorInterface $translator; 26 | 27 | public function setManagerName(string $managerName): void 28 | { 29 | $this->managerName = $managerName; 30 | } 31 | 32 | public function getManagerName(): string 33 | { 34 | return $this->managerName; 35 | } 36 | 37 | public function setTranslator(ContractsTranslatorInterface $translator): void 38 | { 39 | $this->translator = $translator; 40 | } 41 | 42 | protected function getDoctrineManager(): ObjectManager 43 | { 44 | return $this->managerRegistry->getManager($this->managerName); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Controller/ApiController.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class ApiController extends AbstractBaseController 20 | { 21 | private int $lockTimeout = 3600; 22 | private LoggerInterface $logger; 23 | private CommandParser $commandParser; 24 | 25 | public function setLockTimeout(int $lockTimeout): void 26 | { 27 | $this->lockTimeout = $lockTimeout; 28 | } 29 | 30 | public function setLogger(LoggerInterface $logger): void 31 | { 32 | $this->logger = $logger; 33 | } 34 | 35 | public function setCommandParser(CommandParser $commandParser): void 36 | { 37 | $this->commandParser = $commandParser; 38 | } 39 | 40 | /** 41 | * @param ScheduledCommand[] $commands 42 | * @return array 43 | * @throws \Exception 44 | */ 45 | private function getCommandsAsArray(array $commands): array 46 | { 47 | $jsonArray = []; 48 | 49 | foreach ($commands as $command) { 50 | $jsonArray[$command->getName()] = [ 51 | 'NAME' => $command->getName(), 52 | 'COMMAND' => $command->getCommand(), 53 | 'ARGUMENTS' => $command->getArguments(), 54 | 'LAST_RETURN_CODE' => $command->getLastReturnCode(), 55 | 'B_LOCKED' => $command->getLocked(), 56 | 'DH_LAST_EXECUTION' => $command->getLastExecution(), 57 | 'DH_NEXT_EXECUTION' => $command->getNextRunDate(), 58 | 'LOGFILE' => $command->getLogFile(), 59 | ]; 60 | } 61 | 62 | return $jsonArray; 63 | } 64 | 65 | 66 | /** 67 | * List all available (with the allowed namespaces) symfony console commands. 68 | * The commands are grouped by namespaces (like the regular "list" command from symfony 69 | */ 70 | public function getConsoleCommands(): JsonResponse 71 | { 72 | try { 73 | return $this->json($this->commandParser->getCommands()); 74 | } 75 | catch (\Exception $e) { 76 | $this->logger->error('Get Console Commands by API failed', ['message' => $e->getMessage()]); 77 | } 78 | 79 | // StatusCode 417 (error) 80 | return $this->json([], Response::HTTP_EXPECTATION_FAILED); 81 | } 82 | 83 | 84 | /** 85 | * Get Details for symfony console commands (if in allowed namespaces) 86 | * 87 | * @param string $commands all | list of commands , separated 88 | * @example cache:clear,assets:install 89 | */ 90 | public function getConsoleCommandsDetails(string $commands="all"): JsonResponse 91 | { 92 | try { 93 | 94 | if($commands!=="all") 95 | { 96 | return $this->json($this->commandParser->getCommandDetails(explode(",", $commands))); 97 | } 98 | 99 | # all commands 100 | return $this->json($this->commandParser->getAllowedCommandDetails()); 101 | } 102 | catch (\Exception $e) { 103 | $this->logger->error('Get Console Commands details by API failed', ['message' => $e->getMessage()]); 104 | } 105 | 106 | // StatusCode 417 (error) 107 | return $this->json([], Response::HTTP_EXPECTATION_FAILED); 108 | } 109 | 110 | /** 111 | * List all commands. 112 | */ 113 | public function listAction(): JsonResponse 114 | { 115 | $commands = $this->getDoctrineManager() 116 | ->getRepository(ScheduledCommand::class) 117 | ->findAll(); 118 | 119 | return $this->json($this->getCommandsAsArray($commands)); 120 | } 121 | 122 | /** 123 | * External check to monitor the health of the scheduled commands. 124 | * 125 | * method checks if there are jobs which are enabled but did not return 0 on last execution or are locked. 126 | * if a match is found, HTTP status 417 is sent along with an array 127 | * if no matches found, HTTP status 200 is sent with an empty array. 128 | */ 129 | public function monitorAction(): JsonResponse 130 | { 131 | $failedCommands = $this->getDoctrineManager() 132 | ->getRepository(ScheduledCommand::class) 133 | ->findFailedAndTimeoutCommands($this->lockTimeout); 134 | 135 | $jsonArray = $this->getCommandsAsArray($failedCommands); 136 | 137 | if (count($failedCommands) > 1) { 138 | $this->logger->debug( 139 | 'MonitorCommand found locked or timed out commands', 140 | ['amount' => count($failedCommands)] 141 | ); 142 | } else { 143 | // HTTP_OK: no failed or timeout commands 144 | return new JsonResponse(); 145 | } 146 | 147 | $response = new JsonResponse(); 148 | try { 149 | $response->setContent(json_encode($jsonArray, JSON_THROW_ON_ERROR)); 150 | } catch (\JsonException $e) { 151 | $this->logger->error('MonitorCommand failed', ['message' => $e->getMessage()]); 152 | } 153 | 154 | // StatusCode 417 (error) 155 | return $response->setStatusCode(Response::HTTP_EXPECTATION_FAILED); 156 | } 157 | 158 | 159 | /** 160 | * Translate cron expression 161 | * 162 | * @return JsonResponse Status = 0 (ok) 163 | */ 164 | public function translateCronExpression(string $cronExpression, string $lang = 'en'): JsonResponse 165 | { 166 | try{ 167 | if(CronExpressionLib::isValidExpression($cronExpression)) 168 | { 169 | $msg = CronTranslator::translate($cronExpression, $lang); 170 | return new JsonResponse(["status" => 0, "message" => $msg]); 171 | } 172 | 173 | $msg = "Not a valid Cron-Expression"; 174 | return new JsonResponse(["status" => -1, "message" => $msg]); 175 | } 176 | catch (\Exception) 177 | { 178 | $msg = "Could not translate Cron-Expression"; 179 | } 180 | 181 | return new JsonResponse(["status" => -2, "message" => $msg]); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /Controller/DetailController.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class DetailController extends AbstractBaseController 16 | { 17 | /** 18 | * Handle display of new/existing ScheduledCommand object. 19 | */ 20 | public function edit(Request $request, $id = null): Response 21 | { 22 | $validationGroups = []; 23 | $scheduledCommand = $id ? $this->getDoctrineManager()->getRepository(ScheduledCommand::class)->find($id) : null; 24 | if (!$scheduledCommand) { 25 | $scheduledCommand = new ScheduledCommand(); 26 | $validationGroups[] = 'new'; 27 | } 28 | 29 | $form = $this->createForm(ScheduledCommandType::class, $scheduledCommand, [ 30 | 'validation_groups' => $validationGroups 31 | ]); 32 | $form->handleRequest($request); 33 | 34 | if ($form->isSubmitted() && $form->isValid()) { 35 | // check if we have an xml-read error for commands 36 | if ('error' === $scheduledCommand->getCommand()) { 37 | $this->addFlash('error', 'ERROR: please check php bin/console list --format=xml'); 38 | 39 | return $this->redirectToRoute('dukecity_command_scheduler_list'); 40 | } 41 | 42 | $em = $this->getDoctrineManager(); 43 | $em->persist($scheduledCommand); 44 | $em->flush(); 45 | 46 | // Add a flash message and do a redirect to the list 47 | $this->addFlash('success', $this->translator->trans('flash.success', [], 'DukecityCommandScheduler')); 48 | 49 | return $this->redirectToRoute('dukecity_command_scheduler_list'); 50 | } 51 | 52 | return $this->render( 53 | '@DukecityCommandScheduler/Detail/index.html.twig', 54 | ['scheduledCommandForm' => $form->createView()] 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Controller/ListController.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class ListController extends AbstractBaseController 18 | { 19 | private int $lockTimeout = 3600; 20 | private LoggerInterface $logger; 21 | 22 | public function setLockTimeout(int $lockTimeout): void 23 | { 24 | $this->lockTimeout = $lockTimeout; 25 | } 26 | 27 | public function setLogger(LoggerInterface $logger): void 28 | { 29 | $this->logger = $logger; 30 | } 31 | 32 | public function indexAction(): Response 33 | { 34 | $scheduledCommands = $this->getDoctrineManager()->getRepository( 35 | ScheduledCommand::class 36 | )->findAll(); 37 | #)->findAllSortedByNextRuntime(); 38 | 39 | return $this->render( 40 | '@DukecityCommandScheduler/List/index.html.twig', 41 | ['scheduledCommands' => $scheduledCommands] 42 | ); 43 | } 44 | 45 | public function removeAction($id): RedirectResponse 46 | { 47 | $entityManager = $this->getDoctrineManager(); 48 | $scheduledCommand = $entityManager->getRepository(ScheduledCommand::class)->find($id); 49 | $entityManager->remove($scheduledCommand); 50 | $entityManager->flush(); 51 | 52 | // Add a flash message and do a redirect to the list 53 | $this->addFlash('success', $this->translator->trans('flash.deleted', [], 'DukecityCommandScheduler')); 54 | 55 | return $this->redirectToRoute('dukecity_command_scheduler_list'); 56 | } 57 | 58 | /** 59 | * Toggle enabled/disabled. 60 | */ 61 | public function toggleAction($id): RedirectResponse 62 | { 63 | $scheduledCommand = $this->getDoctrineManager()->getRepository(ScheduledCommand::class)->find($id); 64 | $scheduledCommand->setDisabled(!$scheduledCommand->isDisabled()); 65 | $this->getDoctrineManager()->flush(); 66 | 67 | return $this->redirectToRoute('dukecity_command_scheduler_list'); 68 | } 69 | 70 | public function executeAction($id, Request $request): RedirectResponse 71 | { 72 | $scheduledCommand = $this->getDoctrineManager()->getRepository(ScheduledCommand::class)->find($id); 73 | $scheduledCommand->setExecuteImmediately(true); 74 | $this->getDoctrineManager()->flush(); 75 | 76 | // Add a flash message and do a redirect to the list 77 | $this->addFlash('success', $this->translator->trans('flash.execute', ["%name%" => $scheduledCommand->getName()], 'DukecityCommandScheduler')); 78 | 79 | if ($request->query->has('referer')) { 80 | return $this->redirect($request->getSchemeAndHttpHost().urldecode($request->query->get('referer'))); 81 | } 82 | 83 | return $this->redirectToRoute('dukecity_command_scheduler_list'); 84 | } 85 | 86 | public function unlockAction($id, Request $request): RedirectResponse 87 | { 88 | $scheduledCommand = $this->getDoctrineManager()->getRepository(ScheduledCommand::class)->find($id); 89 | $scheduledCommand->setLocked(false); 90 | $this->getDoctrineManager()->flush(); 91 | 92 | // Add a flash message and do a redirect to the list 93 | $this->addFlash('success', $this->translator->trans('flash.unlocked', [], 'DukecityCommandScheduler')); 94 | 95 | if ($request->query->has('referer')) { 96 | return $this->redirect($request->getSchemeAndHttpHost().urldecode($request->query->get('referer'))); 97 | } 98 | 99 | return $this->redirectToRoute('dukecity_command_scheduler_list'); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode(); 23 | 24 | $rootNode 25 | ->children() 26 | ->scalarNode('doctrine_manager')->defaultValue('default')->end() 27 | ->scalarNode('log_path')->defaultValue('%kernel.logs_dir%')->end() 28 | ->scalarNode('lock_timeout')->defaultValue(false)->end() 29 | ->scalarNode('ping_back_provider')->defaultValue(null)->end() 30 | ->booleanNode('ping_back')->defaultValue(true)->end() 31 | ->booleanNode('ping_back_failed')->defaultValue(true)->end() 32 | ->arrayNode('monitor_mail') 33 | ->defaultValue([]) 34 | ->prototype('scalar')->end() 35 | ->end() 36 | ->scalarNode('monitor_mail_subject')->defaultValue('cronjob monitoring %%s, %%s')->end() 37 | ->booleanNode('send_ok')->defaultValue(false)->end() 38 | ->variableNode('excluded_command_namespaces') 39 | ->defaultValue([]) 40 | ->validate() 41 | ->always(function ($value) { 42 | if (null === $value) { 43 | return []; 44 | } 45 | 46 | if (is_string($value)) { 47 | return explode(',', $value); 48 | } 49 | 50 | return $value; 51 | }) 52 | ->end() 53 | ->end() 54 | ->variableNode('included_command_namespaces') 55 | ->defaultValue([]) 56 | ->validate() 57 | ->always(function ($value) { 58 | if (null === $value) { 59 | return []; 60 | } 61 | 62 | if (is_string($value)) { 63 | return explode(',', $value); 64 | } 65 | 66 | return $value; 67 | }) 68 | ->end() 69 | ->end() 70 | ->end(); 71 | 72 | return $treeBuilder; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /DependencyInjection/DukecityCommandSchedulerExtension.php: -------------------------------------------------------------------------------- 1 | processConfiguration(new Configuration(), $configs); 25 | 26 | $loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); 27 | $loader->load('services.php'); 28 | 29 | foreach ($config as $key => $value) { 30 | $container->setParameter('dukecity_command_scheduler.'.$key, $value); 31 | } 32 | } 33 | 34 | /** 35 | * {@inheritdoc} 36 | */ 37 | public function getAlias(): string 38 | { 39 | return 'dukecity_command_scheduler'; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /DukecityCommandSchedulerBundle.php: -------------------------------------------------------------------------------- 1 | addCompilerPass( 29 | new DoctrineOrmMappingsPass( 30 | $driver, 31 | $namespaces, 32 | $managerParameters, 33 | $enabledParameter 34 | ) 35 | ); 36 | 37 | # TODO 38 | /** If this is merged it could be renamed https://github.com/doctrine/DoctrineBundle/pull/1369/files 39 | * new DoctrineOrmMappingsPass( 40 | * DoctrineOrmMappingsPass::createPhpMappingDriver( 41 | * $namespaces, 42 | $directories, 43 | $managerParameters, 44 | $enabledParameter, 45 | $aliasMap) 46 | */ 47 | } 48 | } 49 | 50 | /** 51 | * {@inheritdoc} 52 | */ 53 | public function getContainerExtension(): DukecityCommandSchedulerExtension 54 | { 55 | $class = $this->getContainerExtensionClass(); 56 | 57 | return new $class(); 58 | } 59 | 60 | /** 61 | * {@inheritdoc} 62 | */ 63 | protected function getContainerExtensionClass(): string 64 | { 65 | return DukecityCommandSchedulerExtension::class; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Event/AbstractSchedulerCommandEvent.php: -------------------------------------------------------------------------------- 1 | command; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Event/SchedulerCommandCreatedEvent.php: -------------------------------------------------------------------------------- 1 | failedCommands; 22 | } 23 | 24 | public function getMessage(): string 25 | { 26 | $message = ''; 27 | foreach ($this->failedCommands as $command) { 28 | $message .= sprintf( 29 | "%s: returncode %s, locked: %s, last execution: %s\n", 30 | $command->getName(), 31 | $command->getLastReturnCode(), 32 | $command->getLocked(), 33 | $command->getLastExecution()->format('Y-m-d H:i') 34 | ); 35 | } 36 | 37 | return $message; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Event/SchedulerCommandPostExecutionEvent.php: -------------------------------------------------------------------------------- 1 | |null $profiling 12 | */ 13 | public function __construct( 14 | private ScheduledCommand $command, 15 | private int $result, 16 | private ?OutputInterface $log = null, 17 | private ?array $profiling = null, 18 | private \Exception|\Error|\Throwable|null $exception = null) 19 | { 20 | parent::__construct($command); 21 | } 22 | 23 | public function getResult(): int 24 | { 25 | return $this->result; 26 | } 27 | 28 | public function getLog(): ?OutputInterface 29 | { 30 | return $this->log; 31 | } 32 | 33 | /** 34 | * @return array|null 35 | */ 36 | public function getProfiling(): ?array 37 | { 38 | return $this->profiling; 39 | } 40 | 41 | public function getRuntime(): ?\DateInterval 42 | { 43 | return $this->profiling["runtime"] ?? null; 44 | } 45 | 46 | public function getException(): \Exception|\Error|\Throwable|null 47 | { 48 | return $this->exception; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Event/SchedulerCommandPreExecutionEvent.php: -------------------------------------------------------------------------------- 1 | httpClient = $httpClient ?: HttpClient::create(); 35 | } 36 | 37 | /** 38 | * {@inheritdoc} 39 | */ 40 | public static function getSubscribedEvents(): array 41 | { 42 | return [ 43 | SchedulerCommandCreatedEvent::class => ['onScheduledCommandCreated', -10], 44 | SchedulerCommandFailedEvent::class => ['onScheduledCommandFailed', 20], 45 | SchedulerCommandPreExecutionEvent::class => ['onScheduledCommandPreExecution', 10], 46 | SchedulerCommandPostExecutionEvent::class => ['onScheduledCommandPostExecution', 30], 47 | ]; 48 | } 49 | 50 | // TODO check if useful (could be handled by doctrine lifecycle events) 51 | public function onScheduledCommandCreated(SchedulerCommandCreatedEvent $event): void 52 | { 53 | $this->logger->info('ScheduledCommandCreated', ['name' => $event->getCommand()->getName()]); 54 | } 55 | 56 | public function onScheduledCommandFailed(SchedulerCommandFailedEvent $event): void 57 | { 58 | # notifier is optional 59 | if($this->notifier) 60 | { 61 | //...$this->notifier->getAdminRecipients() 62 | $recipients = []; 63 | foreach ($this->monitor_mail as $mailaddress) { 64 | $recipients[] = new Recipient($mailaddress); 65 | } 66 | 67 | $this->notifier->send(new CronMonitorNotification($event->getFailedCommands(), $this->monitor_mail_subject), ...$recipients); 68 | } 69 | 70 | $this->logger->warning('SchedulerCommandFailedEvent', ['details' => $event->getMessage()]); 71 | } 72 | 73 | public function onScheduledCommandPreExecution(SchedulerCommandPreExecutionEvent $event): void 74 | { 75 | #var_dump('ScheduledCommandPreExecution'); 76 | $this->logger->info('ScheduledCommandPreExecution', ['name' => $event->getCommand()->getName()]); 77 | } 78 | 79 | public function onScheduledCommandPostExecution(SchedulerCommandPostExecutionEvent $event): void 80 | { 81 | #var_dump('ScheduledCommandPostExecution'); 82 | 83 | # success? 84 | if($event->getResult() === 0) 85 | { 86 | $pingBackUrl = $event->getCommand()->getPingBackUrl(); 87 | $check = $this->ping_back; 88 | } 89 | else 90 | { 91 | $pingBackUrl = $event->getCommand()->getPingBackFailedUrl(); 92 | $check = $this->ping_back_failed; 93 | } 94 | 95 | # pingBack 96 | if($check && $this->httpClient && $pingBackUrl) 97 | { 98 | try{ 99 | $response = $this->httpClient->request("POST", $pingBackUrl); 100 | 101 | if($response->getStatusCode() === 200) 102 | { 103 | # correct 104 | $this->logger->debug('ScheduledCommand: PingBack success', [ 105 | 'name' => $event->getCommand()->getName(), 106 | 'pingBackUrl' => $pingBackUrl, 107 | ]); 108 | } 109 | else 110 | { 111 | $this->logger->error('ScheduledCommand: PingBack failed', [ 112 | 'name' => $event->getCommand()->getName(), 113 | 'pingBackUrl' => $pingBackUrl, 114 | 'statusCode' => $response->getStatusCode() 115 | ]); 116 | } 117 | } 118 | catch (\Exception $e) 119 | { 120 | # PingBackFailed 121 | $this->logger->error('ScheduledCommand: PingBack failed', ['name' => $event->getCommand()->getName()]); 122 | } 123 | } 124 | 125 | $this->logger->info('ScheduledCommandPostExecution', [ 126 | 'name' => $event->getCommand()->getName(), 127 | "result" => $event->getResult(), 128 | #"log" => $event->getLog(), 129 | "runtime" => $event->getRuntime()->format('%S seconds'), 130 | #"exception" => $event->getException()?->getMessage() ?? null 131 | ]); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Fixtures/ORM/LoadScheduledCommandData.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class LoadScheduledCommandData implements FixtureInterface 15 | { 16 | protected ?ObjectManager $manager = null; 17 | 18 | /** 19 | * {@inheritdoc} 20 | */ 21 | public function load(ObjectManager $manager): void 22 | { 23 | $this->manager = $manager; 24 | 25 | $now = new \DateTime(); 26 | $today = clone $now; 27 | $beforeYesterday = $now->modify('-2 days'); 28 | 29 | $this->createScheduledCommand('CommandTestOne', 'debug:container', '--help', '@daily', 'one.log', 100, $beforeYesterday); 30 | $this->createScheduledCommand('CommandTestTwo', 'debug:container', '', '@daily', 'two.log', 80, $beforeYesterday, true); 31 | $this->createScheduledCommand('CommandTestThree', 'debug:container', '', '@daily', 'three.log', 60, $today, false, true); 32 | $this->createScheduledCommand('CommandTestFour', 'debug:router', '', '@daily', 'four.log', 40, $today, false, false, true, -1); 33 | $this->createScheduledCommand('CommandTestFive', 'scheduler:test', '0 true', '@daily', 'five.log', 39, $today, false, false, true); 34 | } 35 | 36 | /** 37 | * Create a new ScheduledCommand in database. 38 | */ 39 | protected function createScheduledCommand( 40 | string $name, 41 | string $command, 42 | string $arguments, 43 | string $cronExpression, 44 | string $logFile, 45 | int $priority = 0, 46 | ?\DateTime $lastExecution = null, 47 | bool $locked = false, 48 | bool $disabled = false, 49 | bool $executeNow = false, 50 | ?int $lastReturnCode = null 51 | ): bool { 52 | $this->manager->getConnection()->beginTransaction(); 53 | try { 54 | $scheduledCommand = new ScheduledCommand(); 55 | $scheduledCommand 56 | ->setName($name) 57 | ->setCommand($command) 58 | ->setArguments($arguments) 59 | ->setCronExpression($cronExpression) 60 | ->setLogFile($logFile) 61 | ->setPriority($priority) 62 | ->setLastExecution($lastExecution) 63 | ->setLocked($locked) 64 | ->setDisabled($disabled) 65 | ->setLastReturnCode($lastReturnCode) 66 | ->setExecuteImmediately($executeNow); 67 | 68 | $this->manager->persist($scheduledCommand); 69 | $this->manager->flush(); 70 | $this->manager->getConnection()->commit(); 71 | } catch (\Exception $e) { 72 | #var_dump($e->getMessage()); 73 | $this->manager->getConnection()->rollBack(); 74 | 75 | return false; 76 | } 77 | 78 | return true; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Form/Type/CommandChoiceType.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class CommandChoiceType extends AbstractType 16 | { 17 | public function __construct(private CommandParser $commandParser) 18 | { 19 | } 20 | 21 | /** 22 | * @throws \Exception 23 | */ 24 | public function configureOptions(OptionsResolver $resolver): void 25 | { 26 | $resolver->setDefaults( 27 | [ 28 | 'choices' => $this->commandParser->getCommands(), 29 | ] 30 | ); 31 | } 32 | 33 | /** 34 | * {@inheritdoc} 35 | */ 36 | public function getParent(): string 37 | { 38 | return ChoiceType::class; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Form/Type/ScheduledCommandType.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class ScheduledCommandType extends AbstractType 24 | { 25 | public function buildForm(FormBuilderInterface $builder, array $options): void 26 | { 27 | $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void { 28 | /** @var ScheduledCommand $scheduledCommand */ 29 | $scheduledCommand = $event->getData(); 30 | $form = $event->getForm(); 31 | $options = [ 32 | 'label' => 'detail.name', 33 | 'required' => true, 34 | ]; 35 | 36 | if (! is_null($scheduledCommand->getId())) { 37 | $options['attr'] = ['readonly' => 'readonly']; 38 | } 39 | 40 | $form->add('name', 41 | TextType::class, 42 | $options); 43 | }); 44 | 45 | $builder->add( 46 | 'command', 47 | CommandChoiceType::class, 48 | [ 49 | 'label' => 'detail.command', 50 | 'required' => true, 51 | ] 52 | ); 53 | 54 | $builder->add( 55 | 'arguments', 56 | TextType::class, 57 | [ 58 | 'label' => 'detail.arguments', 59 | 'required' => false, 60 | ] 61 | ); 62 | 63 | $builder->add( 64 | 'cronExpression', 65 | TextType::class, 66 | [ 67 | 'label' => 'detail.cronExpression', 68 | 'required' => true, 69 | ] 70 | ); 71 | 72 | $builder->add( 73 | 'logFile', 74 | TextType::class, 75 | [ 76 | 'label' => 'detail.logFile', 77 | 'required' => false, 78 | 'help' => 'File will be stored to the root "log" folder and append ".log" if not provided.' 79 | ], 80 | ); 81 | 82 | $builder->add( 83 | 'priority', 84 | IntegerType::class, 85 | [ 86 | 'label' => 'detail.priority', 87 | 'empty_data' => 0, 88 | 'required' => false, 89 | ] 90 | ); 91 | 92 | $builder->add( 93 | 'executeImmediately', 94 | CheckboxType::class, 95 | [ 96 | 'label' => 'detail.executeImmediately', 97 | 'required' => false, 98 | ] 99 | ); 100 | 101 | $builder->add( 102 | 'disabled', 103 | CheckboxType::class, 104 | [ 105 | 'label' => 'detail.disabled', 106 | 'required' => false, 107 | ] 108 | ); 109 | 110 | $builder->add( 111 | 'pingBackUrl', 112 | UrlType::class, 113 | [ 114 | 'label' => 'detail.pingBackUrl', 115 | 'required' => false, 116 | ] 117 | ); 118 | 119 | $builder->add( 120 | 'pingBackFailedUrl', 121 | UrlType::class, 122 | [ 123 | 'label' => 'detail.pingBackFailedUrl', 124 | 'required' => false, 125 | ] 126 | ); 127 | 128 | $builder->add( 129 | 'notes', 130 | TextareaType::class, 131 | [ 132 | 'label' => 'detail.notes', 133 | 'required' => false, 134 | 'empty_data' => '' 135 | ] 136 | ); 137 | 138 | $builder->add( 139 | 'save', 140 | SubmitType::class, 141 | [ 142 | 'label' => 'action.save', 143 | ] 144 | ); 145 | } 146 | 147 | public function configureOptions(OptionsResolver $resolver): void 148 | { 149 | $resolver->setDefaults( 150 | [ 151 | 'data_class' => ScheduledCommand::class, 152 | 'wrapper_attr' => 'default_wrapper', 153 | 'translation_domain' => 'DukecityCommandScheduler', 154 | ] 155 | ); 156 | } 157 | 158 | public function getBlockPrefix(): string 159 | { 160 | return 'command_scheduler_detail'; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /Notification/CronMonitorNotification.php: -------------------------------------------------------------------------------- 1 | scheduledCommands as $cmd) { 34 | $arrFailedCommandNames[] = $cmd->getName(); 35 | } 36 | 37 | return new ChatMessage('[CronMonitoring] The following commands need to be checked. 38 | '.implode(', ', $arrFailedCommandNames)); 39 | } 40 | 41 | public function getContent(): string 42 | { 43 | $message = ''; 44 | foreach ($this->scheduledCommands as $command) { 45 | $message .= sprintf( 46 | "%s: returncode %s, locked: %s, last execution: %s\n\n", 47 | $command->getName(), 48 | $command->getLastReturnCode(), 49 | $command->getLocked(), 50 | $command->getLastExecution()->format('Y-m-d H:i') 51 | ); 52 | } 53 | 54 | return "CronMonitoring: The following commands need to be checked.\n\n".$message; 55 | } 56 | 57 | public function asEmailMessage( 58 | Recipient | EmailRecipientInterface $recipient, 59 | string $transport = null 60 | ): ?EmailMessage { 61 | return EmailMessage::fromNotification($this, $recipient); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | CommandSchedulerBundle 2 | ====================== 3 | 4 | [![Code_Checks](https://github.com/Dukecity/CommandSchedulerBundle/actions/workflows/code_checks.yaml/badge.svg?branch=main)](https://github.com/Dukecity/CommandSchedulerBundle/actions/workflows/code_checks.yaml) 5 | [![codecov](https://codecov.io/gh/Dukecity/CommandSchedulerBundle/branch/main/graph/badge.svg?token=V3IZ35QH9D)](https://codecov.io/gh/Dukecity/CommandSchedulerBundle) 6 | 7 | This bundle will allow you to easily manage scheduling for Symfony's console commands (native or not) with cron expression. 8 | See [Wiki](https://github.com/Dukecity/CommandSchedulerBundle/wiki) for Details 9 | 10 | ## Versions & Dependencies 11 | 12 | Please read [Upgrade-News for Version 6](UPGRADE.md) 13 | 14 | Version 6.x (unreleased) has the goal to use modern Php and Symfony features and low maintenance. 15 | So only Php >= 8.2 and Symfony ^7.0 are supported at the moment. 16 | 17 | The following table shows the compatibilities of different versions of the bundle : 18 | 19 | | Version | Symfony | PHP | 20 | |----------------------------------------------------------------------------|----------------|-------| 21 | | [6.x (main)](https://github.com/Dukecity/CommandSchedulerBundle/tree/main) | ^7.0 | >=8.2 | 22 | | [5.x](https://github.com/Dukecity/CommandSchedulerBundle/tree/5.x) | ^5.4 + ^6.0 | >=8.0 | 23 | | [4.x](https://github.com/Dukecity/CommandSchedulerBundle/tree/4.x) | ^4.4.20 + ^5.3 | >=8.0 | 24 | | [3.x](https://github.com/Dukecity/CommandSchedulerBundle/tree/3.x) | ^4.4.20 + ^5.3 | >=7.3 | 25 | | [2.2.x](https://github.com/Dukecity/CommandSchedulerBundle/tree/2.2) | ^3.4 + ^4.3 | ^7.1 | 26 | 27 | 28 | ## Install 29 | 30 | When using Symfony Flex there is an [installation recipe](https://github.com/symfony/recipes-contrib/tree/main/dukecity/command-scheduler-bundle/3.0). 31 | To use it, you have to enable contrib recipes on your project : 32 | 33 | ```sh 34 | composer config extra.symfony.allow-contrib true 35 | composer req dukecity/command-scheduler-bundle 36 | ``` 37 | 38 | #### Update Database 39 | 40 | If you're using DoctrineMigrationsBundle (recommended way): 41 | 42 | ```sh 43 | php bin/console make:migration 44 | php bin/console doctrine:migrations:migrate 45 | ``` 46 | 47 | Without DoctrineMigrationsBundle: 48 | 49 | ```sh 50 | php bin/console doctrine:schema:update --force 51 | ``` 52 | 53 | #### Install Assets 54 | 55 | ```sh 56 | php bin/console assets:install --symlink --relative public 57 | ``` 58 | 59 | #### Secure your route 60 | Add this line to your security config. 61 | 62 | - { path: ^/command-scheduler, role: ROLE_ADMIN } 63 | 64 | Check new URL /command-scheduler/list 65 | 66 | ## Features and Changelog 67 | 68 | Please read [Changelog](CHANGELOG.md) 69 | 70 | ## Screenshots 71 | ![list](Resources/doc/images/scheduled-list.png) 72 | 73 | ![new](Resources/doc/images/new-schedule.png) 74 | 75 | ![new2](Resources/doc/images/command-list.png) 76 | 77 | ## Documentation 78 | 79 | See the [documentation here](https://github.com/Dukecity/CommandSchedulerBundle/wiki). 80 | 81 | ## License 82 | 83 | This bundle is under the MIT license. See the [complete license](Resources/meta/LICENCE) for info. 84 | -------------------------------------------------------------------------------- /Repository/ScheduledCommandRepository.php: -------------------------------------------------------------------------------- 1 | 17 | * @author Julien Guyon 18 | */ 19 | class ScheduledCommandRepository extends EntityRepository 20 | { 21 | /** 22 | * Find all enabled command ordered by priority. 23 | * @return ScheduledCommand[]|null 24 | */ 25 | public function findEnabledCommand(): ?array 26 | { 27 | return $this->findBy(['disabled' => false, 'locked' => false], ['priority' => 'DESC']); 28 | } 29 | 30 | /** 31 | * findAll override to implement the default orderBy clause. 32 | * @inheritdoc 33 | */ 34 | public function findAll(): array 35 | { 36 | return $this->findBy([], ['disabled' => 'ASC', 'priority' => 'DESC']); 37 | } 38 | 39 | /** 40 | * Find all commands ordered by next run time 41 | * 42 | * @throws \Exception 43 | * @return ScheduledCommand[]|null 44 | */ 45 | public function findAllSortedByNextRuntime(): ?array 46 | { 47 | $allCommands = $this->findAll(); 48 | $commands = []; 49 | $now = new \DateTime(); 50 | $future = (new \DateTime())->add(new \DateInterval("P2Y")); 51 | $futureSort = $future->format(DateTimeInterface::ATOM); 52 | 53 | # execution is forced onetimes via isExecuteImmediately 54 | foreach ($allCommands as $command) { 55 | 56 | if($command->getDisabled() || $command->getLocked()) 57 | { 58 | $commands[] = ["order" => $futureSort, "command" => $command]; 59 | continue; 60 | } 61 | 62 | if ($command->isExecuteImmediately()) { 63 | 64 | $commands[] = ["order" => (new \DateTime())->format(DateTimeInterface::ATOM), "command" => $commands]; 65 | } else { 66 | $cron = new CronExpression($command->getCronExpression()); 67 | try { 68 | $nextRunDate = $cron->getNextRunDate($command->getLastExecution()); 69 | 70 | if ($nextRunDate) 71 | {$commands[] = ["order" => $nextRunDate->format(DateTimeInterface::ATOM), "command" => $command];} 72 | else 73 | {$commands[] = ["order" => $futureSort, "command" => $command];} 74 | 75 | } catch (\Exception $e) { 76 | $commands[] = ["order" => $futureSort, "command" => $command]; 77 | } 78 | } 79 | } 80 | 81 | # sort it by "order" 82 | usort($commands, static function($a, $b) { 83 | return $a['order'] <=> $b['order']; 84 | }); 85 | 86 | #var_dump($commands); 87 | 88 | $result = []; 89 | foreach($commands as $cmd) 90 | {$result[] = $cmd["command"];} 91 | 92 | #var_dump($result); 93 | 94 | return $result; 95 | } 96 | 97 | /** 98 | * Find all locked commands. 99 | * 100 | * @return ScheduledCommand[] 101 | */ 102 | public function findLockedCommand(): array 103 | { 104 | return $this->findBy(['disabled' => false, 'locked' => true], ['priority' => 'DESC']); 105 | } 106 | 107 | /** 108 | * Find all failed command. 109 | * 110 | * @return ScheduledCommand[]|null 111 | */ 112 | public function findFailedCommand(): ?array 113 | { 114 | return $this->createQueryBuilder('command') 115 | ->where('command.disabled = :disabled') 116 | ->andWhere('command.lastReturnCode != :lastReturnCode') 117 | ->setParameter('lastReturnCode', 0) 118 | ->setParameter('disabled', false) 119 | ->getQuery() 120 | ->getResult(); 121 | } 122 | 123 | /** 124 | * Find all enabled commands that need to be executed ordered by priority. 125 | * 126 | * @throws \Exception 127 | * @return ScheduledCommand[]|null 128 | */ 129 | public function findCommandsToExecute(): ?array 130 | { 131 | $enabledCommands = $this->findEnabledCommand(); 132 | $commands = []; 133 | $now = new \DateTime(); 134 | 135 | # Get commands which runtime is in the past or 136 | # execution is forced onetimes via isExecuteImmediately 137 | foreach ($enabledCommands as $command) { 138 | if ($command->isExecuteImmediately()) { 139 | $commands[] = $command; 140 | } else { 141 | $cron = new CronExpression($command->getCronExpression()); 142 | try { 143 | $nextRunDate = $cron->getNextRunDate($command->getLastExecution()); 144 | 145 | if ($nextRunDate < $now) { 146 | $commands[] = $command; 147 | } 148 | } catch (\Exception $e) { 149 | } 150 | 151 | } 152 | } 153 | 154 | return $commands; 155 | } 156 | 157 | /** 158 | * @return ScheduledCommand[] 159 | */ 160 | public function findFailedAndTimeoutCommands(int | bool $lockTimeout = false): array 161 | { 162 | // Fist, get all failed commands (return != 0) 163 | $failedCommands = $this->findFailedCommand(); 164 | 165 | // Then, si a timeout value is set, get locked commands and check timeout 166 | if (false !== $lockTimeout) { 167 | $lockedCommands = $this->findLockedCommand(); 168 | foreach ($lockedCommands as $lockedCommand) { 169 | $now = time(); 170 | if ($lockedCommand->getLastExecution()->getTimestamp() + $lockTimeout < $now) { 171 | $failedCommands[] = $lockedCommand; 172 | } 173 | } 174 | } 175 | 176 | return $failedCommands; 177 | } 178 | 179 | /** 180 | * @throws NonUniqueResultException 181 | * @throws TransactionRequiredException 182 | */ 183 | public function getNotLockedCommand(ScheduledCommand $command): ScheduledCommand | null 184 | { 185 | $query = $this->createQueryBuilder('command') 186 | ->where('command.locked = false') 187 | ->andWhere('command.id = :id') 188 | ->setParameter('id', $command->getId()) 189 | ->getQuery(); 190 | 191 | # https://www.doctrine-project.org/projects/doctrine-orm/en/2.8/reference/transactions-and-concurrency.html 192 | $query->setLockMode(LockMode::PESSIMISTIC_WRITE); 193 | 194 | return $query->getOneOrNullResult(); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /Resources/config/routing.php: -------------------------------------------------------------------------------- 1 | add('dukecity_command_scheduler_list', '/command-scheduler/list') 9 | ->defaults(['_controller' => 'Dukecity\CommandSchedulerBundle\Controller\ListController::indexAction']); 10 | 11 | $routingConfigurator->add('dukecity_command_scheduler_monitor', '/command-scheduler/monitor') 12 | ->defaults(['_controller' => 'Dukecity\CommandSchedulerBundle\Controller\ApiController::monitorAction']); 13 | 14 | $routingConfigurator->add('dukecity_command_scheduler_api_list', '/command-scheduler/api/list') 15 | ->defaults(['_controller' => 'Dukecity\CommandSchedulerBundle\Controller\ApiController::listAction']); 16 | 17 | $routingConfigurator->add('dukecity_command_scheduler_api_console_commands', '/command-scheduler/api/console_commands') 18 | ->defaults(['_controller' => 'Dukecity\CommandSchedulerBundle\Controller\ApiController::getConsoleCommands']); 19 | 20 | $routingConfigurator->add('dukecity_command_scheduler_api_console_commands_details', '/command-scheduler/api/console_commands_details/{commands}') 21 | ->defaults( 22 | ['_controller' => 'Dukecity\CommandSchedulerBundle\Controller\ApiController::getConsoleCommandsDetails', 23 | 'commands' => 'all' 24 | ]); 25 | 26 | $routingConfigurator->add('dukecity_command_scheduler_api_translate_cron_expression', 27 | '/command-scheduler/api/trans_cron_expression/{cronExpression}/{lang}') 28 | ->defaults( 29 | ['_controller' => 'Dukecity\CommandSchedulerBundle\Controller\ApiController::translateCronExpression', 30 | 'lang' => 'en' 31 | ]); 32 | 33 | $routingConfigurator->add('dukecity_command_scheduler_action_toggle', '/command-scheduler/action/toggle/{id}') 34 | ->defaults(['_controller' => 'Dukecity\CommandSchedulerBundle\Controller\ListController::toggleAction']); 35 | 36 | $routingConfigurator->add('dukecity_command_scheduler_action_remove', '/command-scheduler/action/remove/{id}') 37 | ->defaults(['_controller' => 'Dukecity\CommandSchedulerBundle\Controller\ListController::removeAction']); 38 | 39 | $routingConfigurator->add('dukecity_command_scheduler_action_execute', '/command-scheduler/action/execute/{id}') 40 | ->defaults(['_controller' => 'Dukecity\CommandSchedulerBundle\Controller\ListController::executeAction']); 41 | 42 | $routingConfigurator->add('dukecity_command_scheduler_action_unlock', '/command-scheduler/action/unlock/{id}') 43 | ->defaults(['_controller' => 'Dukecity\CommandSchedulerBundle\Controller\ListController::unlockAction']); 44 | 45 | $routingConfigurator->add('dukecity_command_scheduler_detail_edit', '/command-scheduler/detail/edit/{id}') 46 | ->defaults(['_controller' => 'Dukecity\CommandSchedulerBundle\Controller\DetailController::edit']); 47 | 48 | $routingConfigurator->add('dukecity_command_scheduler_detail_new', '/command-scheduler/detail/edit') 49 | ->defaults(['_controller' => 'Dukecity\CommandSchedulerBundle\Controller\DetailController::edit']); 50 | }; 51 | -------------------------------------------------------------------------------- /Resources/config/services.php: -------------------------------------------------------------------------------- 1 | services(); 28 | 29 | $services->defaults() 30 | ->public() 31 | ->autowire(true); 32 | 33 | $services->set(DetailController::class) 34 | ->call('setManagerRegistry', [service('doctrine')]) 35 | ->call('setManagerName', ['%dukecity_command_scheduler.doctrine_manager%']) 36 | ->call('setTranslator', [service('translator')]) 37 | ->tag('container.service_subscriber') 38 | ->tag('controller.service_arguments'); 39 | 40 | $services->set(ListController::class) 41 | ->call('setManagerRegistry', [service('doctrine')]) 42 | ->call('setManagerName', ['%dukecity_command_scheduler.doctrine_manager%']) 43 | ->call('setTranslator', [service('translator')]) 44 | ->call('setLockTimeout', ['%dukecity_command_scheduler.lock_timeout%']) 45 | ->call('setLogger', [service('logger')]) 46 | ->tag('container.service_subscriber') 47 | ->tag('controller.service_arguments'); 48 | 49 | $services->set(CommandParser::class) 50 | ->args( 51 | [ 52 | service('kernel'), 53 | '%dukecity_command_scheduler.excluded_command_namespaces%', 54 | '%dukecity_command_scheduler.included_command_namespaces%', 55 | ] 56 | ); 57 | 58 | $services->set(ApiController::class) 59 | ->call('setManagerRegistry', [service('doctrine')]) 60 | ->call('setManagerName', ['%dukecity_command_scheduler.doctrine_manager%']) 61 | ->call('setTranslator', [service('translator')]) 62 | ->call('setLockTimeout', ['%dukecity_command_scheduler.lock_timeout%']) 63 | ->call('setLogger', [service('logger')]) 64 | ->call('setCommandParser', [service(CommandParser::class)]) 65 | ->tag('container.service_subscriber') 66 | ->tag('controller.service_arguments') 67 | ; 68 | 69 | $services->set(CommandSchedulerExecution::class) 70 | ->args( 71 | [ 72 | service('kernel'), 73 | service('parameter_bag'), 74 | service('logger'), 75 | service('event_dispatcher'), 76 | service('doctrine'), 77 | '%dukecity_command_scheduler.doctrine_manager%', 78 | '%dukecity_command_scheduler.log_path%', 79 | ] 80 | ) 81 | #->alias("CommandSchedulerExecution") 82 | ; 83 | 84 | $services->set(CommandChoiceType::class) 85 | ->tag('form.type', ['alias' => 'command_choice']); 86 | 87 | $services->set(ExecuteCommand::class) 88 | ->args( 89 | [ 90 | service(CommandSchedulerExecution::class), 91 | service('event_dispatcher'), 92 | service('doctrine'), 93 | '%dukecity_command_scheduler.doctrine_manager%', 94 | '%dukecity_command_scheduler.log_path%', 95 | ] 96 | ) 97 | ->tag('console.command'); 98 | 99 | $services->set(MonitorCommand::class) 100 | ->args( 101 | [ 102 | service('event_dispatcher'), 103 | service('doctrine'), 104 | service('time.datetime_formatter'), 105 | '%dukecity_command_scheduler.doctrine_manager%', 106 | '%dukecity_command_scheduler.lock_timeout%', 107 | '%dukecity_command_scheduler.monitor_mail%', 108 | '%dukecity_command_scheduler.monitor_mail_subject%', 109 | '%dukecity_command_scheduler.send_ok%', 110 | ] 111 | ) 112 | ->tag('console.command'); 113 | 114 | $services->set(ListCommand::class) 115 | ->args( 116 | [ 117 | service('doctrine'), 118 | service('time.datetime_formatter'), 119 | '%dukecity_command_scheduler.doctrine_manager%', 120 | ] 121 | ) 122 | ->tag('console.command'); 123 | 124 | $services->set(UnlockCommand::class) 125 | ->args( 126 | [ 127 | service('doctrine'), 128 | '%dukecity_command_scheduler.doctrine_manager%', 129 | '%dukecity_command_scheduler.lock_timeout%', 130 | ] 131 | ) 132 | ->tag('console.command'); 133 | 134 | $services->set(AddCommand::class) 135 | ->args( 136 | [ 137 | service('doctrine'), 138 | '%dukecity_command_scheduler.doctrine_manager%', 139 | ] 140 | ) 141 | ->tag('console.command'); 142 | 143 | $services->set(RemoveCommand::class) 144 | ->args( 145 | [ 146 | service('doctrine'), 147 | '%dukecity_command_scheduler.doctrine_manager%', 148 | ] 149 | ) 150 | ->tag('console.command'); 151 | 152 | $services->set(StartSchedulerCommand::class) 153 | ->args( 154 | [ 155 | service('doctrine'), 156 | '%dukecity_command_scheduler.doctrine_manager%', 157 | ] 158 | ) 159 | ->tag('console.command'); 160 | 161 | $services->set(StopSchedulerCommand::class) 162 | ->tag('console.command'); 163 | 164 | $services->set(TestCommand::class) 165 | ->tag('console.command'); 166 | 167 | $services->set(ScheduledCommand::class) 168 | ->tag('controller.service_arguments'); 169 | 170 | $services->set(DisableCommand::class) 171 | ->args( 172 | [ 173 | service('doctrine'), 174 | '%dukecity_command_scheduler.doctrine_manager%' 175 | ] 176 | ) 177 | ->tag('console.command'); 178 | 179 | 180 | if(class_exists(\Symfony\Component\Notifier\NotifierInterface::class)) 181 | {$notifier = service('notifier');} 182 | else { $notifier = null; } 183 | 184 | if(class_exists(\Symfony\Contracts\HttpClient\HttpClientInterface::class)) 185 | {$httpClient = service('http_client');} 186 | else { $httpClient = null; } 187 | 188 | $services->set(SchedulerCommandSubscriber::class) 189 | ->args( 190 | [ 191 | service('logger'), 192 | service('doctrine.orm.default_entity_manager'), 193 | $notifier, 194 | $httpClient, 195 | '%dukecity_command_scheduler.monitor_mail%', 196 | '%dukecity_command_scheduler.monitor_mail_subject%', 197 | '%dukecity_command_scheduler.ping_back_provider%', 198 | '%dukecity_command_scheduler.ping_back%', 199 | '%dukecity_command_scheduler.ping_back_failed%', 200 | ] 201 | ) 202 | ->tag('kernel.event_subscriber'); 203 | }; 204 | -------------------------------------------------------------------------------- /Resources/config/validation.php: -------------------------------------------------------------------------------- 1 | extension('namespaces', ['CommandSchedulerConstraints' => 'Dukecity\CommandSchedulerBundle\Validator\Constraints\\']); 10 | 11 | $containerConfigurator->extension( 12 | ScheduledCommand::class, 13 | ['properties' => [ 14 | 'cronExpression' => [ 15 | ['NotBlank' => null], 16 | ['CommandSchedulerConstraints:CronExpression' => ['message' => 'commandScheduler.validation.cron']], 17 | ], 18 | 'name' => [['NotBlank' => null]], 19 | 'command' => [['NotBlank' => null]], 20 | 'priority' => [['Type' => ['type' => 'integer']]], 21 | ], 22 | ] 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /Resources/doc/images/command-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dukecity/CommandSchedulerBundle/3574fdfa636f96c06a29a2f0e7370e4c68d32e07/Resources/doc/images/command-list.png -------------------------------------------------------------------------------- /Resources/doc/images/new-schedule.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dukecity/CommandSchedulerBundle/3574fdfa636f96c06a29a2f0e7370e4c68d32e07/Resources/doc/images/new-schedule.png -------------------------------------------------------------------------------- /Resources/doc/images/scheduled-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dukecity/CommandSchedulerBundle/3574fdfa636f96c06a29a2f0e7370e4c68d32e07/Resources/doc/images/scheduled-list.png -------------------------------------------------------------------------------- /Resources/doc/index.md: -------------------------------------------------------------------------------- 1 | See Wiki at https://github.com/Dukecity/CommandSchedulerBundle/wiki 2 | -------------------------------------------------------------------------------- /Resources/doc/integrations/easyadmin/ScheduledCommandCrudController.php: -------------------------------------------------------------------------------- 1 | setEntityLabelInSingular('ScheduledCommand') 41 | ->setEntityLabelInPlural('ScheduledCommands') 42 | ->setSearchFields(['name', 'command']); 43 | } 44 | 45 | public function configureFields(string $pageName): iterable 46 | { 47 | # translation_domain: 'DukecityCommandScheduler' 48 | #$translationDomain = $context->getI18n()->setTranslationDomain(); 49 | $id = IdField::new('id', 'ID')->hideOnForm()->setSortable(false)->hideOnIndex(); 50 | $name = TextField::new('name'); 51 | 52 | # EasyAdmin3 could not handle multidimensional Arrays ;( 53 | $command = ChoiceField::new('command') 54 | #->setChoices($this->commandParser->getCommands()) 55 | ->setChoices($this->commandParser->reduceNamespacedCommands($this->commandParser->getCommands())) 56 | ->setFormType(CommandChoiceType::class) 57 | ; 58 | 59 | $arguments = TextField::new('arguments'); 60 | $cronExpression = TextField::new('cronExpression'); 61 | 62 | $logFile = TextField::new('logFile'); 63 | $priority = IntegerField::new('priority'); 64 | $lastExecution = DateTimeField::new('lastExecution'); 65 | $lastReturnCode = IntegerField::new('lastReturnCode'); 66 | $disabled = BooleanField::new('disabled'); 67 | $locked = BooleanField::new('locked'); 68 | $executeImmediately = BooleanField::new('executeImmediately'); 69 | #$description = TextareaField::new('description'); 70 | #$createdAt = DateTimeField::new('createdAt'); 71 | 72 | # LISTING 73 | if (Crud::PAGE_INDEX === $pageName) { 74 | return [ 75 | $id, 76 | $name, 77 | $disabled, 78 | $command, 79 | $arguments, 80 | $cronExpression, 81 | $priority, 82 | $lastExecution->setFormat('short', 'short'), 83 | $lastReturnCode, 84 | $locked 85 | ]; 86 | } 87 | 88 | # CREATE/EDIT 89 | return [ 90 | FormField::addPanel('Basic information'), 91 | $id, 92 | $name, 93 | $command, 94 | $arguments, 95 | $cronExpression, 96 | $priority, 97 | $logFile, 98 | $disabled, 99 | $executeImmediately 100 | ]; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Resources/doc/integrations/easyadmin/index.md: -------------------------------------------------------------------------------- 1 | # EasyAdmin integration 2 | 3 | If you want to manage your scheduled commands via [EasyAdmin](https://github.com/EasyCorp/EasyAdminBundle) here is a configuration template that you can copy/paste and change to your needs. 4 | 5 | ## EasyAdmin v.3 6 | 7 | This Version uses .php-Files for configuration. 8 | Copy the file "ScheduledCommandCrudController.php" to the /Controller/ Folder of your EasyAdmin-Installation. 9 | 10 | 11 | ## EasyAdmin v.2 12 | 13 | This Version uses .yaml-Files for configuration 14 | 15 | ```yaml 16 | easy_admin: 17 | entities: 18 | Cron: 19 | translation_domain: 'JMoseCommandScheduler' 20 | label: 'list.title' 21 | class: JMose\CommandSchedulerBundle\Entity\ScheduledCommand 22 | list: 23 | title: "list.title" 24 | fields: 25 | - { property: 'id', label: 'ID' } 26 | - { property: 'name', label: 'detail.name' } 27 | - { property: 'command', label: 'detail.command' } 28 | - { property: 'arguments', label: 'detail.arguments' } 29 | - { property: 'lastExecution', label: 'detail.lastExecution' } 30 | - { property: 'lastReturncode', label: 'detail.lastReturnCode' } 31 | - { property: 'locked', label: 'detail.locked', type: boolean} 32 | - { property: 'priority', label: 'detail.priority' } 33 | - { property: 'disabled', label: 'detail.disabled' } 34 | actions: 35 | - { name: 'jmose_command_scheduler_action_execute', type: 'route', label: 'action.execute' } 36 | - { name: 'jmose_command_scheduler_action_unlock', type: 'route', label: 'action.unlock' } 37 | form: 38 | fields: 39 | - { property: 'name', label: 'detail.name' } 40 | - { property: 'command', label: 'detail.command', type: 'JMose\CommandSchedulerBundle\Form\Type\CommandChoiceType' } 41 | - { property: 'arguments', label: 'detail.arguments' } 42 | - { property: 'cronExpression', label: 'detail.cronExpression' } 43 | - { property: 'priority', label: 'detail.priority' } 44 | - { property: 'disabled', label: 'detail.disabled' } 45 | - { property: 'logFile', label: 'detail.logFile' } 46 | new: 47 | fields: 48 | - { property: 'executeImmediately', label: 'detail.executeImmediately' } 49 | ``` 50 | -------------------------------------------------------------------------------- /Resources/doc/integrations/events/CustomSchedulerCommandSubscriber.php: -------------------------------------------------------------------------------- 1 | ['onScheduledCommandCreated', -10], 24 | SchedulerCommandFailedEvent::class => ['onScheduledCommandFailed', 20], 25 | SchedulerCommandPreExecutionEvent::class => ['onScheduledCommandPreExecution', 10], 26 | SchedulerCommandPostExecutionEvent::class => ['onScheduledCommandPostExecution', 30], 27 | ]; 28 | } 29 | 30 | public function onScheduledCommandCreated(SchedulerCommandCreatedEvent $event): void 31 | { 32 | $this->logger->info('CustomScheduledCommandCreated', ['name' => $event->getCommand()->getName()]); 33 | } 34 | 35 | public function onScheduledCommandFailed(SchedulerCommandFailedEvent $event): void 36 | { 37 | $this->logger->warning('CustomSchedulerCommandFailedEvent', ['details' => $event->getMessage()]); 38 | } 39 | 40 | public function onScheduledCommandPreExecution(SchedulerCommandPreExecutionEvent $event): void 41 | { 42 | $this->logger->info('CustomScheduledCommandPreExecution', ['name' => $event->getCommand()->getName()]); 43 | } 44 | 45 | public function onScheduledCommandPostExecution(SchedulerCommandPostExecutionEvent $event): void 46 | { 47 | $this->logger->info('CustomScheduledCommandPostExecution', [ 48 | 'name' => $event->getCommand()->getName(), 49 | "result" => $event->getResult(), 50 | "runtime" => $event->getRuntime()->format('%S seconds'), 51 | ]); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Resources/doc/integrations/events/index.md: -------------------------------------------------------------------------------- 1 | # Events 2 | 3 | There are 4 Events actual. This feature is still in development and maybe change over time. 4 | 5 | | Event | Info | 6 | | ------------------------------------ | -------------------------------- | 7 | | `SchedulerCommandCreatedEvent` | After an command was created | 8 | | `SchedulerCommandFailedEvent` | Commands Failed from MonitorCall | 9 | | `SchedulerCommandPreExecutionEvent` | Before Execution of an command | 10 | | `SchedulerCommandPostExecutionEvent` | After Execution of an command | 11 | 12 | 13 | ## EventSubscriber 14 | 15 | You can subscribe to Events which are fired from the Bundle. 16 | 17 | The file "SchedulerCommandSubscriber.php" is an example how you can subscribe to them. 18 | It will add an additional logging at the moment. 19 | Just copy the file to your /src/EventSubscriber/ Folder and adjust it to your needs. 20 | -------------------------------------------------------------------------------- /Resources/meta/LICENCE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Julien GUYON 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 | -------------------------------------------------------------------------------- /Resources/public/css/bootstrap-reboot.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Reboot v4.6.0 (https://getbootstrap.com/) 3 | * Copyright 2011-2021 The Bootstrap Authors 4 | * Copyright 2011-2021 Twitter, Inc. 5 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 6 | * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md) 7 | */ 8 | *, 9 | *::before, 10 | *::after { 11 | box-sizing: border-box; 12 | } 13 | 14 | html { 15 | font-family: sans-serif; 16 | line-height: 1.15; 17 | -webkit-text-size-adjust: 100%; 18 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 19 | } 20 | 21 | article, aside, figcaption, figure, footer, header, hgroup, main, nav, section { 22 | display: block; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 28 | font-size: 1rem; 29 | font-weight: 400; 30 | line-height: 1.5; 31 | color: #212529; 32 | text-align: left; 33 | background-color: #fff; 34 | } 35 | 36 | [tabindex="-1"]:focus:not(:focus-visible) { 37 | outline: 0 !important; 38 | } 39 | 40 | hr { 41 | box-sizing: content-box; 42 | height: 0; 43 | overflow: visible; 44 | } 45 | 46 | h1, h2, h3, h4, h5, h6 { 47 | margin-top: 0; 48 | margin-bottom: 0.5rem; 49 | } 50 | 51 | p { 52 | margin-top: 0; 53 | margin-bottom: 1rem; 54 | } 55 | 56 | abbr[title], 57 | abbr[data-original-title] { 58 | text-decoration: underline; 59 | -webkit-text-decoration: underline dotted; 60 | text-decoration: underline dotted; 61 | cursor: help; 62 | border-bottom: 0; 63 | -webkit-text-decoration-skip-ink: none; 64 | text-decoration-skip-ink: none; 65 | } 66 | 67 | address { 68 | margin-bottom: 1rem; 69 | font-style: normal; 70 | line-height: inherit; 71 | } 72 | 73 | ol, 74 | ul, 75 | dl { 76 | margin-top: 0; 77 | margin-bottom: 1rem; 78 | } 79 | 80 | ol ol, 81 | ul ul, 82 | ol ul, 83 | ul ol { 84 | margin-bottom: 0; 85 | } 86 | 87 | dt { 88 | font-weight: 700; 89 | } 90 | 91 | dd { 92 | margin-bottom: .5rem; 93 | margin-left: 0; 94 | } 95 | 96 | blockquote { 97 | margin: 0 0 1rem; 98 | } 99 | 100 | b, 101 | strong { 102 | font-weight: bolder; 103 | } 104 | 105 | small { 106 | font-size: 80%; 107 | } 108 | 109 | sub, 110 | sup { 111 | position: relative; 112 | font-size: 75%; 113 | line-height: 0; 114 | vertical-align: baseline; 115 | } 116 | 117 | sub { 118 | bottom: -.25em; 119 | } 120 | 121 | sup { 122 | top: -.5em; 123 | } 124 | 125 | a { 126 | color: #007bff; 127 | text-decoration: none; 128 | background-color: transparent; 129 | } 130 | 131 | a:hover { 132 | color: #0056b3; 133 | text-decoration: underline; 134 | } 135 | 136 | a:not([href]):not([class]) { 137 | color: inherit; 138 | text-decoration: none; 139 | } 140 | 141 | a:not([href]):not([class]):hover { 142 | color: inherit; 143 | text-decoration: none; 144 | } 145 | 146 | pre, 147 | code, 148 | kbd, 149 | samp { 150 | font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 151 | font-size: 1em; 152 | } 153 | 154 | pre { 155 | margin-top: 0; 156 | margin-bottom: 1rem; 157 | overflow: auto; 158 | -ms-overflow-style: scrollbar; 159 | } 160 | 161 | figure { 162 | margin: 0 0 1rem; 163 | } 164 | 165 | img { 166 | vertical-align: middle; 167 | border-style: none; 168 | } 169 | 170 | svg { 171 | overflow: hidden; 172 | vertical-align: middle; 173 | } 174 | 175 | table { 176 | border-collapse: collapse; 177 | } 178 | 179 | caption { 180 | padding-top: 0.75rem; 181 | padding-bottom: 0.75rem; 182 | color: #6c757d; 183 | text-align: left; 184 | caption-side: bottom; 185 | } 186 | 187 | th { 188 | text-align: inherit; 189 | text-align: -webkit-match-parent; 190 | } 191 | 192 | label { 193 | display: inline-block; 194 | margin-bottom: 0.5rem; 195 | } 196 | 197 | button { 198 | border-radius: 0; 199 | } 200 | 201 | button:focus:not(:focus-visible) { 202 | outline: 0; 203 | } 204 | 205 | input, 206 | button, 207 | select, 208 | optgroup, 209 | textarea { 210 | margin: 0; 211 | font-family: inherit; 212 | font-size: inherit; 213 | line-height: inherit; 214 | } 215 | 216 | button, 217 | input { 218 | overflow: visible; 219 | } 220 | 221 | button, 222 | select { 223 | text-transform: none; 224 | } 225 | 226 | [role="button"] { 227 | cursor: pointer; 228 | } 229 | 230 | select { 231 | word-wrap: normal; 232 | } 233 | 234 | button, 235 | [type="button"], 236 | [type="reset"], 237 | [type="submit"] { 238 | -webkit-appearance: button; 239 | } 240 | 241 | button:not(:disabled), 242 | [type="button"]:not(:disabled), 243 | [type="reset"]:not(:disabled), 244 | [type="submit"]:not(:disabled) { 245 | cursor: pointer; 246 | } 247 | 248 | button::-moz-focus-inner, 249 | [type="button"]::-moz-focus-inner, 250 | [type="reset"]::-moz-focus-inner, 251 | [type="submit"]::-moz-focus-inner { 252 | padding: 0; 253 | border-style: none; 254 | } 255 | 256 | input[type="radio"], 257 | input[type="checkbox"] { 258 | box-sizing: border-box; 259 | padding: 0; 260 | } 261 | 262 | textarea { 263 | overflow: auto; 264 | resize: vertical; 265 | } 266 | 267 | fieldset { 268 | min-width: 0; 269 | padding: 0; 270 | margin: 0; 271 | border: 0; 272 | } 273 | 274 | legend { 275 | display: block; 276 | width: 100%; 277 | max-width: 100%; 278 | padding: 0; 279 | margin-bottom: .5rem; 280 | font-size: 1.5rem; 281 | line-height: inherit; 282 | color: inherit; 283 | white-space: normal; 284 | } 285 | 286 | progress { 287 | vertical-align: baseline; 288 | } 289 | 290 | [type="number"]::-webkit-inner-spin-button, 291 | [type="number"]::-webkit-outer-spin-button { 292 | height: auto; 293 | } 294 | 295 | [type="search"] { 296 | outline-offset: -2px; 297 | -webkit-appearance: none; 298 | } 299 | 300 | [type="search"]::-webkit-search-decoration { 301 | -webkit-appearance: none; 302 | } 303 | 304 | ::-webkit-file-upload-button { 305 | font: inherit; 306 | -webkit-appearance: button; 307 | } 308 | 309 | output { 310 | display: inline-block; 311 | } 312 | 313 | summary { 314 | display: list-item; 315 | cursor: pointer; 316 | } 317 | 318 | template { 319 | display: none; 320 | } 321 | 322 | [hidden] { 323 | display: none !important; 324 | } 325 | /*# sourceMappingURL=bootstrap-reboot.css.map */ -------------------------------------------------------------------------------- /Resources/public/css/bootstrap-reboot.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Reboot v4.6.0 (https://getbootstrap.com/) 3 | * Copyright 2011-2021 The Bootstrap Authors 4 | * Copyright 2011-2021 Twitter, Inc. 5 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 6 | * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md) 7 | */*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}[tabindex="-1"]:focus:not(:focus-visible){outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#007bff;text-decoration:none;background-color:transparent}a:hover{color:#0056b3;text-decoration:underline}a:not([href]):not([class]){color:inherit;text-decoration:none}a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto;-ms-overflow-style:scrollbar}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#6c757d;text-align:left;caption-side:bottom}th{text-align:inherit;text-align:-webkit-match-parent}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important} 8 | /*# sourceMappingURL=bootstrap-reboot.min.css.map */ -------------------------------------------------------------------------------- /Resources/public/css/custom.css: -------------------------------------------------------------------------------- 1 | 2 | input[type="checkbox"], input[type="radio"] { 3 | width: 20px; 4 | height: 20px; 5 | } 6 | 7 | #dukecity .action-container a { 8 | display: inline-block; 9 | width: 24px; 10 | margin-left: 2px; 11 | margin-right: 2px; 12 | } 13 | 14 | #dukecity .action-container a i { 15 | font-size: 1.4rem; 16 | } 17 | 18 | .jumbotron { 19 | margin-bottom: auto; 20 | } -------------------------------------------------------------------------------- /Resources/public/css/dataTables.bootstrap4.min.css: -------------------------------------------------------------------------------- 1 | table.dataTable{clear:both;margin-top:6px !important;margin-bottom:6px !important;max-width:none !important;border-collapse:separate !important;border-spacing:0}table.dataTable td,table.dataTable th{-webkit-box-sizing:content-box;box-sizing:content-box}table.dataTable td.dataTables_empty,table.dataTable th.dataTables_empty{text-align:center}table.dataTable.nowrap th,table.dataTable.nowrap td{white-space:nowrap}div.dataTables_wrapper div.dataTables_length label{font-weight:normal;text-align:left;white-space:nowrap}div.dataTables_wrapper div.dataTables_length select{width:auto;display:inline-block}div.dataTables_wrapper div.dataTables_filter{text-align:right}div.dataTables_wrapper div.dataTables_filter label{font-weight:normal;white-space:nowrap;text-align:left}div.dataTables_wrapper div.dataTables_filter input{margin-left:.5em;display:inline-block;width:auto}div.dataTables_wrapper div.dataTables_info{padding-top:.85em}div.dataTables_wrapper div.dataTables_paginate{margin:0;white-space:nowrap;text-align:right}div.dataTables_wrapper div.dataTables_paginate ul.pagination{margin:2px 0;white-space:nowrap;justify-content:flex-end}div.dataTables_wrapper div.dataTables_processing{position:absolute;top:50%;left:50%;width:200px;margin-left:-100px;margin-top:-26px;text-align:center;padding:1em 0}table.dataTable>thead>tr>th:active,table.dataTable>thead>tr>td:active{outline:none}table.dataTable>thead>tr>th:not(.sorting_disabled),table.dataTable>thead>tr>td:not(.sorting_disabled){padding-right:30px}table.dataTable>thead .sorting,table.dataTable>thead .sorting_asc,table.dataTable>thead .sorting_desc,table.dataTable>thead .sorting_asc_disabled,table.dataTable>thead .sorting_desc_disabled{cursor:pointer;position:relative}table.dataTable>thead .sorting:before,table.dataTable>thead .sorting:after,table.dataTable>thead .sorting_asc:before,table.dataTable>thead .sorting_asc:after,table.dataTable>thead .sorting_desc:before,table.dataTable>thead .sorting_desc:after,table.dataTable>thead .sorting_asc_disabled:before,table.dataTable>thead .sorting_asc_disabled:after,table.dataTable>thead .sorting_desc_disabled:before,table.dataTable>thead .sorting_desc_disabled:after{position:absolute;bottom:.9em;display:block;opacity:.3}table.dataTable>thead .sorting:before,table.dataTable>thead .sorting_asc:before,table.dataTable>thead .sorting_desc:before,table.dataTable>thead .sorting_asc_disabled:before,table.dataTable>thead .sorting_desc_disabled:before{right:1em;content:"↑"}table.dataTable>thead .sorting:after,table.dataTable>thead .sorting_asc:after,table.dataTable>thead .sorting_desc:after,table.dataTable>thead .sorting_asc_disabled:after,table.dataTable>thead .sorting_desc_disabled:after{right:.5em;content:"↓"}table.dataTable>thead .sorting_asc:before,table.dataTable>thead .sorting_desc:after{opacity:1}table.dataTable>thead .sorting_asc_disabled:before,table.dataTable>thead .sorting_desc_disabled:after{opacity:0}div.dataTables_scrollHead table.dataTable{margin-bottom:0 !important}div.dataTables_scrollBody table{border-top:none;margin-top:0 !important;margin-bottom:0 !important}div.dataTables_scrollBody table thead .sorting:before,div.dataTables_scrollBody table thead .sorting_asc:before,div.dataTables_scrollBody table thead .sorting_desc:before,div.dataTables_scrollBody table thead .sorting:after,div.dataTables_scrollBody table thead .sorting_asc:after,div.dataTables_scrollBody table thead .sorting_desc:after{display:none}div.dataTables_scrollBody table tbody tr:first-child th,div.dataTables_scrollBody table tbody tr:first-child td{border-top:none}div.dataTables_scrollFoot>.dataTables_scrollFootInner{box-sizing:content-box}div.dataTables_scrollFoot>.dataTables_scrollFootInner>table{margin-top:0 !important;border-top:none}@media screen and (max-width: 767px){div.dataTables_wrapper div.dataTables_length,div.dataTables_wrapper div.dataTables_filter,div.dataTables_wrapper div.dataTables_info,div.dataTables_wrapper div.dataTables_paginate{text-align:center}div.dataTables_wrapper div.dataTables_paginate ul.pagination{justify-content:center !important}}table.dataTable.table-sm>thead>tr>th:not(.sorting_disabled){padding-right:20px}table.dataTable.table-sm .sorting:before,table.dataTable.table-sm .sorting_asc:before,table.dataTable.table-sm .sorting_desc:before{top:5px;right:.85em}table.dataTable.table-sm .sorting:after,table.dataTable.table-sm .sorting_asc:after,table.dataTable.table-sm .sorting_desc:after{top:5px}table.table-bordered.dataTable{border-right-width:0}table.table-bordered.dataTable th,table.table-bordered.dataTable td{border-left-width:0}table.table-bordered.dataTable th:last-child,table.table-bordered.dataTable th:last-child,table.table-bordered.dataTable td:last-child,table.table-bordered.dataTable td:last-child{border-right-width:1px}table.table-bordered.dataTable tbody th,table.table-bordered.dataTable tbody td{border-bottom-width:0}div.dataTables_scrollHead table.table-bordered{border-bottom-width:0}div.table-responsive>div.dataTables_wrapper>div.row{margin:0}div.table-responsive>div.dataTables_wrapper>div.row>div[class^=col-]:first-child{padding-left:0}div.table-responsive>div.dataTables_wrapper>div.row>div[class^=col-]:last-child{padding-right:0} 2 | -------------------------------------------------------------------------------- /Resources/public/css/fonts/bootstrap-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dukecity/CommandSchedulerBundle/3574fdfa636f96c06a29a2f0e7370e4c68d32e07/Resources/public/css/fonts/bootstrap-icons.woff -------------------------------------------------------------------------------- /Resources/public/css/fonts/bootstrap-icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dukecity/CommandSchedulerBundle/3574fdfa636f96c06a29a2f0e7370e4c68d32e07/Resources/public/css/fonts/bootstrap-icons.woff2 -------------------------------------------------------------------------------- /Resources/public/js/dataTables.bootstrap4.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | DataTables Bootstrap 4 integration 3 | ©2011-2017 SpryMedia Ltd - datatables.net/license 4 | */ 5 | var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.findInternal=function(a,b,c){a instanceof String&&(a=String(a));for(var e=a.length,d=0;d<'col-sm-12 col-md-6'f>><'row'<'col-sm-12'tr>><'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>", 12 | renderer:"bootstrap"});a.extend(d.ext.classes,{sWrapper:"dataTables_wrapper dt-bootstrap4",sFilterInput:"form-control form-control-sm",sLengthSelect:"custom-select custom-select-sm form-control form-control-sm",sProcessing:"dataTables_processing card",sPageButton:"paginate_button page-item"});d.ext.renderer.pageButton.bootstrap=function(f,l,A,B,m,t){var u=new d.Api(f),C=f.oClasses,n=f.oLanguage.oPaginate,D=f.oLanguage.oAria.paginate||{},h,k,v=0,y=function(q,w){var x,E=function(p){p.preventDefault(); 13 | a(p.currentTarget).hasClass("disabled")||u.page()==p.data.action||u.page(p.data.action).draw("page")};var r=0;for(x=w.length;r",{"class":C.sPageButton+" "+k,id:0===A&&"string"===typeof g?f.sTableId+"_"+g:null}).append(a("",{href:"#","aria-controls":f.sTableId,"aria-label":D[g],"data-dt-idx":v,tabindex:f.iTabIndex,"class":"page-link"}).html(h)).appendTo(q);f.oApi._fnBindAction(F,{action:g},E);v++}}}};try{var z=a(l).find(c.activeElement).data("dt-idx")}catch(q){}y(a(l).empty().html('