diff --git a/composer.json b/composer.json index 79f9f6f..2a7ee55 100644 --- a/composer.json +++ b/composer.json @@ -14,11 +14,6 @@ "type": "symfony-bundle", "license": "MIT", "authors": [ - { - "name": "Baudouin Douliery", - "email": "bdouliery@clever-age.com", - "role": "Developer" - }, { "name": "Grégory Tonon", "email": "gtonon@clever-age.com", @@ -41,7 +36,7 @@ } }, "require": { - "php": ">=8.1", + "php": ">=8.2", "ext-ctype": "*", "ext-iconv": "*", "cleverage/process-bundle": "dev-prepare-release", @@ -50,24 +45,14 @@ "doctrine/doctrine-bundle": "^2.5", "doctrine/doctrine-migrations-bundle": "^3.2", "doctrine/orm": "^2.9 || ^3.0", - "easycorp/easyadmin-bundle": "^4.0", - "league/flysystem": "^3.0", + "easycorp/easyadmin-bundle": "^4.8", "symfony/doctrine-messenger": "^6.4|^7.1", - "symfony/filesystem": "^6.4|^7.1", - "symfony/form": "^6.4|^7.1", - "symfony/framework-bundle": "^6.4|^7.1", + "symfony/dotenv": "^6.4|^7.1", "symfony/messenger": "^6.4|^7.1", - "symfony/mime": "^6.4|^7.1", "symfony/runtime": "^6.4|^7.1", - "symfony/security-bundle": "^6.4|^7.1", - "symfony/stopwatch": "^6.4|^7.1", - "symfony/twig-bundle": "^6.4|^7.1", - "symfony/validator": "^6.4|^7.1", - "symfony/webpack-encore-bundle": "^1.13|^2.0", - "symfony/yaml": "^6.4|^7.1", - "syonix/monolog-parser": "dev-master", - "twig/extra-bundle": "^3.8", - "twig/intl-extra": "^3.8" + "symfony/scheduler": "^6.4|^7.1", + "symfony/string": "^6.4|^7.1", + "symfony/uid": "^6.4|^7.1" }, "require-dev": { "doctrine/doctrine-fixtures-bundle": "^3.4", diff --git a/config/routes.yaml b/config/routes.yaml deleted file mode 100755 index f70ff14..0000000 --- a/config/routes.yaml +++ /dev/null @@ -1,4 +0,0 @@ -_cleverage_ui_process: - prefix: '/process' - resource: ../src/Controller/ - type: attribute diff --git a/config/services/command.yaml b/config/services/command.yaml index 73f5b02..ef5a20d 100644 --- a/config/services/command.yaml +++ b/config/services/command.yaml @@ -1,13 +1,4 @@ services: - cleverage_ui_process.command.purge_process_execution: - class: CleverAge\ProcessUiBundle\Command\PurgeProcessExecution - public: false - tags: - - { name: console.command } - arguments: - - '@cleverage_ui_process.repository.process_execution' - - '%process_logs_dir%' - cleverage_ui_process.command.user_create: class: CleverAge\ProcessUiBundle\Command\UserCreateCommand public: false diff --git a/config/services/controller.yaml b/config/services/controller.yaml index 90405a3..38b6790 100644 --- a/config/services/controller.yaml +++ b/config/services/controller.yaml @@ -4,7 +4,12 @@ services: autowire: true autoconfigure: true bind: - $processLogDir: '%process_logs_dir%' - $indexLogs: '%clever_age_process_ui.index_logs.enabled%' + $processConfigurationsManager: '@cleverage_ui_process.manager.process_configuration' + $localeSwitcher: '@translation.locale_switcher' + $request: '@request_stack' + $messageBus: '@messenger.default_bus' + $uploadDirectory: '%upload_directory%' + $context: '@EasyCorp\Bundle\EasyAdminBundle\Factory\AdminContextFactory' + $logDirectory: '%kernel.logs_dir%' tags: - { name: 'controller.service_arguments' } diff --git a/config/services/event_subscriber.yaml b/config/services/event_subscriber.yaml index 8aed4df..8b27d91 100644 --- a/config/services/event_subscriber.yaml +++ b/config/services/event_subscriber.yaml @@ -5,18 +5,6 @@ services: tags: - { name: 'kernel.event_subscriber' } arguments: - - '@doctrine.orm.entity_manager' - - '@cleverage_ui_process.repository.process' - - '@cleverage_ui_process.monolog_handler.process_log' - - '@messenger.default_bus' - - '@cleverage_ui_process.manager.configuration' - - '%process_logs_dir%' - - '%clever_age_process_ui.index_logs.enabled%' - - cleverage_ui_process.event_subscriber.crud.process: - class: CleverAge\ProcessUiBundle\EventSubscriber\Crud\ProcessCrudListener - public: false - tags: - - { name: 'kernel.event_subscriber' } - arguments: - - '@cleverage_ui_process.repository.process' + - '@cleverage_ui_process.monolog_handler.process' + - '@cleverage_ui_process.monolog_handler.doctrine_process' + - '@cleverage_ui_process.manager.process_execution' diff --git a/config/services/http_value_resolver.yaml b/config/services/http_value_resolver.yaml new file mode 100644 index 0000000..8e0c0ce --- /dev/null +++ b/config/services/http_value_resolver.yaml @@ -0,0 +1,12 @@ +services: + cleverage_ui_process.http_value_resolver.http_process_execute: + class: CleverAge\ProcessUiBundle\Http\ValueResolver\HttpProcessExecuteValueResolver + public: false + arguments: + - '%upload_directory%' + + cleverage_ui_process.http_value_resolver.process_configuration: + class: CleverAge\ProcessUiBundle\Http\ValueResolver\ProcessConfigurationValueResolver + public: false + arguments: + - '@cleverage_process.registry.process_configuration' diff --git a/config/services/manager.yaml b/config/services/manager.yaml index 973a110..45edb15 100644 --- a/config/services/manager.yaml +++ b/config/services/manager.yaml @@ -1,9 +1,12 @@ services: - cleverage_ui_process.manager.configuration: - class: CleverAge\ProcessUiBundle\Manager\ProcessUiConfigurationManager + cleverage_ui_process.manager.process_execution: + class: CleverAge\ProcessUiBundle\Manager\ProcessExecutionManager public: false arguments: - - '@cleverage_process.registry.process_configuration' + - '@cleverage_ui_process.repository.process_execution' - CleverAge\ProcessUiBundle\Manager\ProcessUiConfigurationManager: - alias: 'cleverage_ui_process.manager.configuration' + cleverage_ui_process.manager.process_configuration: + class: CleverAge\ProcessUiBundle\Manager\ProcessConfigurationsManager + public: false + arguments: + - '@cleverage_process.registry.process_configuration' diff --git a/config/services/message.yaml b/config/services/message.yaml index af59384..eaa3873 100644 --- a/config/services/message.yaml +++ b/config/services/message.yaml @@ -1,13 +1,14 @@ services: - cleverage_ui_process.message.log_indexer_handler: - class: CleverAge\ProcessUiBundle\Message\LogIndexerHandler + cleverage_ui_process.message.cron_process_message_handler: + class: CleverAge\ProcessUiBundle\Message\CronProcessMessageHandler public: false arguments: - - '@doctrine' + - '@messenger.default_bus' - cleverage_ui_process.message.process_run_handler: - class: CleverAge\ProcessUiBundle\Message\ProcessRunHandler + cleverage_ui_process.message.process_execute_handler: + class: CleverAge\ProcessUiBundle\Message\ProcessExecuteHandler public: false arguments: - - '@cleverage_process.command.execute_process' + - '@cleverage_process.manager.process' + - '@cleverage_ui_process.monolog_handler.process' diff --git a/config/services/monolog_handler.yaml b/config/services/monolog_handler.yaml index cacc088..294f525 100644 --- a/config/services/monolog_handler.yaml +++ b/config/services/monolog_handler.yaml @@ -1,7 +1,15 @@ services: - cleverage_ui_process.monolog_handler.process_log: - class: CleverAge\ProcessUiBundle\Monolog\Handler\ProcessLogHandler + cleverage_ui_process.monolog_handler.doctrine_process: + class: CleverAge\ProcessUiBundle\Monolog\Handler\DoctrineProcessHandler + public: false + calls: + - [ setEntityManager, [ '@doctrine.orm.entity_manager' ] ] + - [ setProcessExecutionManager, [ '@cleverage_ui_process.manager.process_execution' ] ] + + cleverage_ui_process.monolog_handler.process: + class: CleverAge\ProcessUiBundle\Monolog\Handler\ProcessHandler public: false arguments: - - '%process_logs_dir%' + - '%kernel.logs_dir%' + - '@cleverage_ui_process.manager.process_execution' diff --git a/config/services/parameters.yaml b/config/services/parameters.yaml index e10f4a1..3cee611 100644 --- a/config/services/parameters.yaml +++ b/config/services/parameters.yaml @@ -1,2 +1,2 @@ parameters: - process_logs_dir: '%kernel.logs_dir%/process' + upload_directory: '%kernel.project_dir%/var/storage/uploads' diff --git a/config/services/repository.yaml b/config/services/repository.yaml index 0456ccc..13ec0ca 100644 --- a/config/services/repository.yaml +++ b/config/services/repository.yaml @@ -5,16 +5,8 @@ services: arguments: - '@doctrine.orm.entity_manager' - cleverage_ui_process.repository.process: - class: CleverAge\ProcessUiBundle\Repository\ProcessRepository - public: false - arguments: - - '@doctrine.orm.entity_manager' - - '@cleverage_ui_process.manager.configuration' - - '@cleverage_process.registry.process_configuration' - - cleverage_ui_process.repository.user: - class: CleverAge\ProcessUiBundle\Repository\UserRepository + cleverage_ui_process.repository.process_schedule: + class: CleverAge\ProcessUiBundle\Repository\ProcessScheduleRepository public: false arguments: - '@doctrine.orm.entity_manager' diff --git a/config/services/scheduler.yaml b/config/services/scheduler.yaml new file mode 100644 index 0000000..794570a --- /dev/null +++ b/config/services/scheduler.yaml @@ -0,0 +1,8 @@ +services: + cleverage_ui_process.scheduler.cron: + class: CleverAge\ProcessUiBundle\Scheduler\CronScheduler + public: false + arguments: + - '@cleverage_ui_process.repository.process_schedule' + - '@validator' + - '@logger' diff --git a/config/services/security.yaml b/config/services/security.yaml index ba5a36d..fa54359 100644 --- a/config/services/security.yaml +++ b/config/services/security.yaml @@ -1,7 +1,7 @@ services: - cleverage_ui_process.security.login_form_auth_authenticator: - class: CleverAge\ProcessUiBundle\Security\LoginFormAuthAuthenticator + cleverage_ui_process.security.http_process_execution_authenticator: + class: CleverAge\ProcessUiBundle\Security\HttpProcessExecutionAuthenticator public: false arguments: - - '@router' + - '@doctrine.orm.entity_manager' diff --git a/config/services/twig.yaml b/config/services/twig.yaml new file mode 100644 index 0000000..7c031bf --- /dev/null +++ b/config/services/twig.yaml @@ -0,0 +1,25 @@ +services: + cleverage_ui_process.twig.log_level_extension: + class: CleverAge\ProcessUiBundle\Twig\Extension\LogLevelExtension + public: false + tags: + - { name: 'twig.extension' } + + cleverage_ui_process.twig.md5_extension: + class: CleverAge\ProcessUiBundle\Twig\Extension\MD5Extension + public: false + tags: + - { name: 'twig.extension' } + + cleverage_ui_process.twig.process_execution_extension: + class: CleverAge\ProcessUiBundle\Twig\Extension\ProcessExecutionExtension + public: false + tags: + - { name: 'twig.extension' } + + cleverage_ui_process.twig.process_execution_extension_runtime: + class: CleverAge\ProcessUiBundle\Twig\Runtime\ProcessExecutionExtensionRuntime + public: false + arguments: + - '@cleverage_ui_process.repository.process_execution' + - '@cleverage_ui_process.manager.process_configuration' diff --git a/config/services/validator.yaml b/config/services/validator.yaml new file mode 100644 index 0000000..e876335 --- /dev/null +++ b/config/services/validator.yaml @@ -0,0 +1,21 @@ +services: + cleverage_ui_process.validator.cron_expression_validator: + class: CleverAge\ProcessUiBundle\Validator\CronExpressionValidator + public: false + tags: + - { name: 'validator.constraint_validator' } + + cleverage_ui_process.validator.every_expression_validator: + class: CleverAge\ProcessUiBundle\Validator\EveryExpressionValidator + public: false + tags: + - { name: 'validator.constraint_validator' } + + cleverage_ui_process.validator.is_valid_process_code: + class: CleverAge\ProcessUiBundle\Validator\IsValidProcessCodeValidator + public: false + tags: + - { name: 'validator.constraint_validator' } + arguments: + - '@cleverage_process.registry.process_configuration' + diff --git a/docs/index.md b/docs/index.md index 0e45131..6a9311c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,62 +22,104 @@ CleverAge\UiProcessBundle\CleverAgeUiProcessBundle::class => ['all' => true], ## Import routes ```yaml -#config/routes.yaml -ui-process: - resource: '@CleverAgeUiProcessBundle/Resources/config/routes.yaml' +ui-process-bundle: + resource: '@CleverAgeProcessUiBundle/src/Controller' + type: attribute ``` * Run doctrine migration * Create an user using cleverage:ui-process:user-create console. Now you can access Process UI via http://your-domain.com/process -## Indexing logs +## Features -You can index logs line into database to perform search on ****Process > History**** page. -See configuration section. +### Launch process via http request +You can launch a process via http post request +First you need to generate a token via UI User edit form. The ProcessUi generate for you a auth token (keep it in secured area, it will display once). -When indexation is enabled you can perform it async. +It' all, now you can launch a process via http post request -```yaml -#config/messenger.yaml -framework: - messenger: - transports: - log_index: 'doctrine://default' - - routing: - CleverAge\UiProcessBundle\Message\LogIndexerMessage: log_index +***Curl sample*** +```bash +make bash +curl --location 'http://localhost/http/process/execute?code=demo.die' \ +--header 'Authorization: Bearer 3da8409b5f5b640fb0c43d68e8ac8d23' \ +--form 'input=@"/file.csv"' \ +--form 'context[context_1]="FOO"' \ +--form 'context[context_2]="BAR"' ``` +* Query string code parameter must be a valid process code +* Header Autorization: Bearer is the previously generated token +* input could be string or file representation +* context you can pass multiple context values -Then you have to consume messages by running (use a supervisor to keep consumer alive) -``` -bin/console messenger:consume log_index --memory-limit=64M -``` -See official `symfony/messenger` component documentations for more informations https://symfony.com/doc/current/messenger.html +### Scheduler +You can schedule process execution via UI using cron expression (*/5 * * * *) or periodical triggers (5 seconds) +For more details about cron expression and periodical triggers visit +https://symfony.com/doc/6.4/scheduler.html#cron-expression-triggers and https://symfony.com/doc/6.4/scheduler.html#periodical-triggers -## Manual EasyAdmin integration +In order to make scheduler process working be sure the following command is running +```bash +bin/console messenger:consume scheduler_cron +``` +See more details about ***messenger:consume*** command in consume message section -### Integrate CrudController +## Consume Messages +Symfony messenger is used in order to run process via UI or schedule process -Of course, you can integrate UiProcess CRUD into your own easy admin Dashboard -```php - public function configureMenuItems(): iterable - { - /* ... your configuration */ - yield MenuItem::linkToCrud('History', null, ProcessExecution::class); - } +*To consume process launched via UI make sure the following command is running* +```bash +bin/console messenger:consume execute_process ``` -### Configuration +*To consume scheduled process make sure the following command is running* +```bash +bin/console messenger:consume scheduler_cron +``` +You can pass some options to messenger:consume command +``` +Options: + -l, --limit=LIMIT Limit the number of received messages + -f, --failure-limit=FAILURE-LIMIT The number of failed messages the worker can consume + -m, --memory-limit=MEMORY-LIMIT The memory limit the worker can consume + -t, --time-limit=TIME-LIMIT The time limit in seconds the worker can handle new messages + --sleep=SLEEP Seconds to sleep before asking for new messages after no messages were found [default: 1] + -b, --bus=BUS Name of the bus to which received messages should be dispatched (if not passed, bus is determined automatically) + --queues=QUEUES Limit receivers to only consume from the specified queues (multiple values allowed) + --no-reset Do not reset container services after each message +``` -```yaml -#config/cleverage_process_ui.yaml -cleverage_ui_process: - index_logs: - enabled: false - level: ERROR #Minimum log level to index. Allowed values are DEBUG, INFO, NOTICE, WARNING, ERROR, CRITICAL, ALERT, EMERGENCY +It's recommended to use supervisor app or equivalent to keep command alive + +***Sample supervisor configuration*** ``` +[program:scheduler] +command=php /var/www/html/bin/console messenger:consume scheduler_cron +autostart=false +autorestart=true +startretries=1 +startsecs=1 +redirect_stderr=true +stderr_logfile=/var/log/supervisor.scheduler-err.log +stdout_logfile=/var/log/supervisor.scheduler-out.log +user=www-data +killasgroup=true +stopasgroup=true + +[program:process] +command=php /var/www/html/bin/console messenger:consume execute_process +autostart=false +autorestart=true +startretries=1 +startsecs=1 +redirect_stderr=true +stderr_logfile=/var/log/supervisor.process-err.log +stdout_logfile=/var/log/supervisor.process-out.log +user=www-data +killasgroup=true +stopasgroup=true +``` ## Reference diff --git a/public/logo.jpg b/public/logo.jpg new file mode 100644 index 0000000..d110ec9 Binary files /dev/null and b/public/logo.jpg differ diff --git a/src/Admin/Field/EnumField.php b/src/Admin/Field/EnumField.php new file mode 100644 index 0000000..7d5a406 --- /dev/null +++ b/src/Admin/Field/EnumField.php @@ -0,0 +1,30 @@ +setProperty($propertyName) + ->setLabel($label) + ->setTemplatePath('@CleverAgeProcessUi/admin/field/enum.html.twig'); + } +} diff --git a/src/Admin/Field/LogLevelField.php b/src/Admin/Field/LogLevelField.php new file mode 100644 index 0000000..f0d4c68 --- /dev/null +++ b/src/Admin/Field/LogLevelField.php @@ -0,0 +1,30 @@ +setProperty($propertyName) + ->setLabel($label) + ->setTemplatePath('@CleverAgeProcessUi/admin/field/log_level.html.twig'); + } +} diff --git a/src/Admin/Filter/LogProcessFilter.php b/src/Admin/Filter/LogProcessFilter.php new file mode 100644 index 0000000..ed884a0 --- /dev/null +++ b/src/Admin/Filter/LogProcessFilter.php @@ -0,0 +1,60 @@ + $executionId]; + } + + return (new self()) + ->setFilterFqcn(self::class) + ->setProperty('process') + ->setLabel($label) + ->setFormType(ChoiceFilterType::class) + ->setFormTypeOption('value_type_options', ['choices' => $choices]) + ->setFormTypeOption('data', ['comparison' => ComparisonType::EQ, 'value' => $executionId]); + } + + public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, ?FieldDto $fieldDto, EntityDto $entityDto): void + { + $value = $filterDataDto->getValue(); + $queryBuilder->join('entity.processExecution', 'pe'); + if (is_numeric($value)) { + $queryBuilder->andWhere($queryBuilder->expr()->eq('pe.id', ':id')); + $queryBuilder->setParameter('id', $value); + + return; + } + $queryBuilder->where('pe.code IN (:codes)'); + $queryBuilder->setParameter('codes', $filterDataDto->getValue()); + } +} diff --git a/src/CleverAgeProcessUiBundle.php b/src/CleverAgeProcessUiBundle.php index c68bf2c..698933b 100644 --- a/src/CleverAgeProcessUiBundle.php +++ b/src/CleverAgeProcessUiBundle.php @@ -13,28 +13,10 @@ namespace CleverAge\ProcessUiBundle; -use CleverAge\ProcessUiBundle\DependencyInjection\Compiler\RegisterLogHandlerCompilerPass; -use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; class CleverAgeProcessUiBundle extends Bundle { - public const ICON_NEW = 'fa fa-plus'; - public const ICON_EDIT = 'far fa-edit'; - public const ICON_DELETE = 'fa fa-trash-o'; - public const LABEL_NEW = false; - public const LABEL_EDIT = false; - public const LABEL_DELETE = false; - public const CLASS_NEW = ''; - public const CLASS_EDIT = 'text-warning'; - public const CLASS_DELETE = ''; - - public function build(ContainerBuilder $container): void - { - parent::build($container); - $container->addCompilerPass(new RegisterLogHandlerCompilerPass()); - } - public function getPath(): string { return \dirname(__DIR__); diff --git a/src/Command/PurgeProcessExecution.php b/src/Command/PurgeProcessExecution.php deleted file mode 100644 index a7c19ed..0000000 --- a/src/Command/PurgeProcessExecution.php +++ /dev/null @@ -1,80 +0,0 @@ -setDefinition( - new InputDefinition([ - new InputOption( - 'days', - 'd', - InputOption::VALUE_OPTIONAL, - 'Days to keep. Default 180', - 180 - ), - new InputOption( - 'remove-files', - 'rf', - InputOption::VALUE_NEGATABLE, - 'Remove log files ? (default false)', - false - ), - ]) - ); - } - - public function execute(InputInterface $input, OutputInterface $output): int - { - $days = $input->getOption('days'); - $removeFiles = $input->getOption('remove-files'); - $date = new \DateTime(); - $date->modify("-$days day"); - if ($removeFiles) { - $finder = new Finder(); - $fs = new Filesystem(); - $finder->in($this->processLogDir)->date('before '.$date->format(\DateTimeInterface::ATOM)); - $count = $finder->count(); - $fs->remove($finder); - $output->writeln("$count log files are deleted on filesystem."); - } - $this->processExecutionRepository->deleteBefore($date); - - $output->writeln(<<Process Execution before {$date->format(\DateTimeInterface::ATOM)} are deleted into database. - EOT); - - return Command::SUCCESS; - } -} diff --git a/src/Command/UserCreateCommand.php b/src/Command/UserCreateCommand.php index 0591c7b..17a8480 100644 --- a/src/Command/UserCreateCommand.php +++ b/src/Command/UserCreateCommand.php @@ -17,17 +17,23 @@ use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; +use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\Email; use Symfony\Component\Validator\Constraints\Length; use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Validator\ValidatorInterface; -#[AsCommand(name: 'cleverage:process-ui:user-create', description: 'Command to create a new admin into database for process ui.')] -final class UserCreateCommand extends Command +#[AsCommand( + name: 'cleverage:process-ui:user-create', + description: 'Command to create a new admin into database for process ui.' +)] +class UserCreateCommand extends Command { public function __construct( private readonly ValidatorInterface $validator, @@ -41,7 +47,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $style = new SymfonyStyle($input, $output); $username = $this->ask('Please enter the email.', $style, [new Email()]); - $password = $this->ask('Please enter the user password.', $style, [new NotBlank(), new Length(min: 8)]); + + $password = $this->askPassword( + (new Question('Please enter the user password.'))->setHidden(true)->setHiddenFallback(false), + $input, + $output + ); $user = new User(); $user->setEmail($username); @@ -57,7 +68,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } /** - * @param array $constraints + * @param Constraint[] $constraints */ private function ask(string $question, SymfonyStyle $style, array $constraints = []): mixed { @@ -72,4 +83,21 @@ private function ask(string $question, SymfonyStyle $style, array $constraints = return $value; } + + private function askPassword(Question $question, InputInterface $input, OutputInterface $output): mixed + { + $constraints = [new NotBlank(), new Length(min: 8)]; + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $password = $helper->ask($input, $output, $question); + $violations = $this->validator->validate($password, $constraints); + while ($violations->count() > 0) { + $violationsMessage = $violations->get(0)->getMessage(); + $output->writeln("$violationsMessage"); + $password = $helper->ask($input, $output, $question); + $violations = $this->validator->validate($password, $constraints); + } + + return $password; + } } diff --git a/src/Controller/Admin/LogRecordCrudController.php b/src/Controller/Admin/LogRecordCrudController.php new file mode 100644 index 0000000..57d2831 --- /dev/null +++ b/src/Controller/Admin/LogRecordCrudController.php @@ -0,0 +1,96 @@ +setMaxLength(512), + DateTimeField::new('createdAt')->setFormat('Y/M/dd H:mm:ss'), + ArrayField::new('context') + ->setTemplatePath('@CleverAgeProcessUi/admin/field/array.html.twig') + ->onlyOnDetail(), + BooleanField::new('contextIsEmpty', 'Has context info ?') + ->onlyOnIndex() + ->renderAsSwitch(false), + ]; + } + + public function configureCrud(Crud $crud): Crud + { + return $crud->showEntityActionsInlined()->setPaginatorPageSize(250); + } + + public function configureActions(Actions $actions): Actions + { + return Actions::new() + ->add(Crud::PAGE_INDEX, Action::new('detail', false, 'fas fa-eye') + ->setHtmlAttributes( + [ + 'data-bs-toggle' => 'tooltip', + 'data-bs-placement' => 'top', + 'title' => 'Show details', + ] + ) + ->linkToCrudAction('detail')) + ->add(Crud::PAGE_DETAIL, 'index'); + } + + public function configureFilters(Filters $filters): Filters + { + $id = $this->request->getMainRequest()?->query->all('filters')['process']['value'] ?? null; + $processList = $this->processConfigurationsManager->getPublicProcesses(); + $processList = array_map(fn (ProcessConfiguration $cfg) => $cfg->getCode(), $processList); + + return $filters->add( + LogProcessFilter::new('process', $processList, $id) + )->add( + ChoiceFilter::new('level')->setChoices(array_combine(Level::NAMES, Level::VALUES)) + )->add('message')->add('context')->add('createdAt'); + } +} diff --git a/src/Controller/Admin/Process/LaunchAction.php b/src/Controller/Admin/Process/LaunchAction.php new file mode 100644 index 0000000..d68af22 --- /dev/null +++ b/src/Controller/Admin/Process/LaunchAction.php @@ -0,0 +1,112 @@ + '\w+'], + methods: ['POST', 'GET'] +)] +#[IsGranted('ROLE_USER')] +class LaunchAction extends AbstractController +{ + public function __invoke( + RequestStack $requestStack, + MessageBusInterface $messageBus, + string $uploadDirectory, + #[ValueResolver('process')] ProcessConfiguration $processConfiguration, + ProcessConfigurationsManager $processConfigurationsManager, + AdminContext $context, + ): Response { + $uiOptions = $processConfigurationsManager->getUiOptions($requestStack->getMainRequest()?->get('process') ?? ''); + $form = $this->createForm( + LaunchType::class, + null, + [ + 'constraints' => $uiOptions['constraints'] ?? [], + 'process_code' => $requestStack->getMainRequest()?->get('process'), + ] + ); + if (false === $form->isSubmitted()) { + $default = $uiOptions['default'] ?? []; + if (false === $form->get('input')->getConfig()->getType()->getInnerType() instanceof TextType + && isset($default['input']) + ) { + unset($default['input']); + } + $form->setData($default); + } + $form->handleRequest($requestStack->getMainRequest()); + if ($form->isSubmitted() && $form->isValid()) { + $input = $form->get('input')->getData(); + if ($input instanceof UploadedFile) { + $filename = \sprintf('%s/%s.%s', $uploadDirectory, Uuid::v4(), $input->getClientOriginalExtension()); + (new Filesystem())->dumpFile($filename, $input->getContent()); + $input = $filename; + } + + $message = new ProcessExecuteMessage( + $form->getConfig()->getOption('process_code'), + $input, + array_merge( + ['execution_user' => $this->getUser()?->getEmail()], + $form->get('context')->getData() + ) + ); + $messageBus->dispatch($message); + $this->addFlash( + 'success', + 'Process has been added to queue. It will start as soon as possible' + ); + + return $this->redirectToRoute('process', ['routeName' => 'process_list']); + } + $context->getAssets()->addJsAsset(Asset::fromEasyAdminAssetPackage('field-collection.js')->getAsDto()); + + return $this->render( + '@CleverAgeProcessUi/admin/process/launch.html.twig', + [ + 'form' => $form->createView(), + ] + ); + } + + protected function getUser(): ?User + { + /** @var User $user */ + $user = parent::getUser(); + + return $user; + } +} diff --git a/src/Controller/Admin/Process/ListAction.php b/src/Controller/Admin/Process/ListAction.php new file mode 100644 index 0000000..e6fd4b6 --- /dev/null +++ b/src/Controller/Admin/Process/ListAction.php @@ -0,0 +1,35 @@ +render( + '@CleverAgeProcessUi/admin/process/list.html.twig', + [ + 'processes' => $processConfigurationsManager->getPublicProcesses(), + ] + ); + } +} diff --git a/src/Controller/Admin/Process/UploadAndExecuteAction.php b/src/Controller/Admin/Process/UploadAndExecuteAction.php new file mode 100644 index 0000000..614f587 --- /dev/null +++ b/src/Controller/Admin/Process/UploadAndExecuteAction.php @@ -0,0 +1,81 @@ + '\w+'], + methods: ['POST', 'GET'] +)] +#[IsGranted('ROLE_USER')] +class UploadAndExecuteAction extends AbstractController +{ + public function __invoke( + RequestStack $requestStack, + MessageBusInterface $messageBus, + string $uploadDirectory, + #[ValueResolver('process')] ProcessConfiguration $processConfiguration, + ): Response { + if (!$processConfiguration->getEntryPoint() instanceof TaskConfiguration) { + throw new \RuntimeException('You must set an entry_point.'); + } + $form = $this->createForm( + ProcessUploadFileType::class, + null, + ['process_code' => $requestStack->getMainRequest()?->get('process')] + ); + $form->handleRequest($requestStack->getMainRequest()); + if ($form->isSubmitted() && $form->isValid()) { + /** @var UploadedFile $file */ + $file = $form->getData(); + $savedFilepath = \sprintf('%s/%s.%s', $uploadDirectory, Uuid::v4(), $file->getClientOriginalExtension()); + (new Filesystem())->dumpFile($savedFilepath, $file->getContent()); + $messageBus->dispatch( + new ProcessExecuteMessage( + $form->getConfig()->getOption('process_code'), + $savedFilepath + ) + ); + $this->addFlash( + 'success', + 'Process has been added to queue. It will start as soon as possible' + ); + + return $this->redirectToRoute('process', ['routeName' => 'process_list']); + } + + return $this->render( + '@CleverAgeProcessUi/admin/process/upload_and_execute.html.twig', + [ + 'form' => $form->createView(), + ] + ); + } +} diff --git a/src/Controller/Admin/ProcessDashboardController.php b/src/Controller/Admin/ProcessDashboardController.php new file mode 100644 index 0000000..22ee1c0 --- /dev/null +++ b/src/Controller/Admin/ProcessDashboardController.php @@ -0,0 +1,83 @@ +container->get(AdminUrlGenerator::class); + + return $this->redirect($adminUrlGenerator->setController(ProcessExecutionCrudController::class)->generateUrl()); + } + + public function configureDashboard(): Dashboard + { + return Dashboard::new() + ->setTitle(''); + } + + public function configureMenuItems(): iterable + { + yield MenuItem::linkToDashboard('Dashboard', 'fa fa-home'); + yield MenuItem::subMenu('Process', 'fas fa-gear')->setSubItems( + [ + MenuItem::linkToRoute('Process list', 'fas fa-list', 'process_list'), + MenuItem::linkToCrud('Executions', 'fas fa-rocket', ProcessExecution::class), + MenuItem::linkToCrud('Logs', 'fas fa-pen', LogRecord::class), + MenuItem::linkToCrud('Scheduler', 'fas fa-solid fa-clock', ProcessSchedule::class), + ] + ); + if ($this->isGranted('ROLE_ADMIN')) { + yield MenuItem::subMenu('Users', 'fas fa-user')->setSubItems( + [ + MenuItem::linkToCrud('User List', 'fas fa-user', User::class), + ] + ); + } + } + + public function configureCrud(): Crud + { + /** @var ?User $user */ + $user = $this->getUser(); + if (null !== $user?->getLocale()) { + $this->localeSwitcher->setLocale($user->getLocale()); + } + + return parent::configureCrud()->setTimezone($user?->getTimezone() ?? date_default_timezone_get()); + } +} diff --git a/src/Controller/Admin/ProcessExecutionCrudController.php b/src/Controller/Admin/ProcessExecutionCrudController.php new file mode 100644 index 0000000..b5a9666 --- /dev/null +++ b/src/Controller/Admin/ProcessExecutionCrudController.php @@ -0,0 +1,140 @@ +setFormat('Y/M/dd H:mm:ss'), + DateTimeField::new('endDate')->setFormat('Y/M/dd H:mm:ss'), + TextField::new('source')->setTemplatePath('@CleverAgeProcessUi/admin/field/process_source.html.twig'), + TextField::new('target')->setTemplatePath('@CleverAgeProcessUi/admin/field/process_target.html.twig'), + TextField::new('duration')->formatValue(function ($value, ProcessExecution $entity) { + return $entity->duration(); // returned format can be changed here + }), + ArrayField::new('report')->setTemplatePath('@CleverAgeProcessUi/admin/field/report.html.twig'), + ArrayField::new('context')->setTemplatePath('@CleverAgeProcessUi/admin/field/report.html.twig'), + ]; + } + + public function configureCrud(Crud $crud): Crud + { + $crud->showEntityActionsInlined(); + $crud->setDefaultSort(['startDate' => 'DESC']); + + return $crud; + } + + public function configureActions(Actions $actions): Actions + { + return Actions::new() + ->add( + Crud::PAGE_INDEX, + Action::new('showLogs', false, 'fas fa-eye') + ->setHtmlAttributes( + [ + 'data-bs-toggle' => 'tooltip', + 'data-bs-placement' => 'top', + 'title' => 'Show logs stored in database', + ] + ) + ->linkToCrudAction('showLogs') + )->add( + Crud::PAGE_INDEX, + Action::new('downloadLogfile', false, 'fas fa-download') + ->setHtmlAttributes( + [ + 'data-bs-toggle' => 'tooltip', + 'data-bs-placement' => 'top', + 'title' => 'Download log file', + ] + ) + ->linkToCrudAction('downloadLogFile') + ); + } + + public function showLogs(AdminContext $adminContext): RedirectResponse + { + /** @var AdminUrlGenerator $adminUrlGenerator */ + $adminUrlGenerator = $this->container->get(AdminUrlGenerator::class); + $url = $adminUrlGenerator + ->setController(LogRecordCrudController::class) + ->setAction('index') + ->setEntityId(null) + ->set( + 'filters', + [ + 'process' => [ + 'comparison' => '=', + 'value' => $this->getContext()?->getEntity()->getInstance()->getId(), + ], + ] + ) + ->generateUrl(); + + return $this->redirect($url); + } + + public function downloadLogFile( + AdminContext $context, + string $logDirectory, + ): Response { + /** @var ProcessExecution $processExecution */ + $processExecution = $context->getEntity()->getInstance(); + $filepath = $logDirectory.\DIRECTORY_SEPARATOR.$processExecution->code.\DIRECTORY_SEPARATOR + .$processExecution->logFilename; + $basename = basename($filepath); + $content = file_get_contents($filepath); + if (false === $content) { + throw new NotFoundHttpException('Log file not found.'); + } + $response = new Response($content); + $response->headers->set('Content-Type', 'text/plain; charset=utf-8'); + $response->headers->set('Content-Disposition', "attachment; filename=\"$basename\""); + + return $response; + } + + public function configureFilters(Filters $filters): Filters + { + return $filters->add('code')->add('startDate'); + } +} diff --git a/src/Controller/Admin/ProcessScheduleCrudController.php b/src/Controller/Admin/ProcessScheduleCrudController.php new file mode 100644 index 0000000..c7c6526 --- /dev/null +++ b/src/Controller/Admin/ProcessScheduleCrudController.php @@ -0,0 +1,122 @@ +setPageTitle('index', 'Scheduler') + ->showEntityActionsInlined(); + } + + public function configureActions(Actions $actions): Actions + { + return $actions + ->update(Crud::PAGE_INDEX, Action::NEW, fn (Action $action) => $action->setIcon('fa fa-plus') + ->setLabel(false) + ->addCssClass(''))->update(Crud::PAGE_INDEX, Action::EDIT, fn (Action $action) => $action->setIcon('fa fa-edit') + ->setLabel(false) + ->addCssClass('text-warning'))->update(Crud::PAGE_INDEX, Action::DELETE, fn (Action $action) => $action->setIcon('fa fa-trash-o') + ->setLabel(false) + ->addCssClass(''))->update(Crud::PAGE_INDEX, Action::BATCH_DELETE, fn (Action $action) => $action->setLabel('Delete') + ->addCssClass('')); + } + + public static function getEntityFqcn(): string + { + return ProcessSchedule::class; + } + + public function configureFields(string $pageName): iterable + { + $choices = array_map(fn (ProcessConfiguration $configuration) => [$configuration->getCode()], $this->processConfigurationsManager->getPublicProcesses()); + + return [ + FormField::addTab('General'), + TextField::new('process') + ->setFormType(ChoiceType::class) + ->setFormTypeOption('choices', array_combine(array_keys($choices), array_keys($choices))), + EnumField::new('type') + ->setFormType(EnumType::class) + ->setFormTypeOption('class', ProcessScheduleType::class), + TextField::new('expression'), + DateTimeField::new('nextExecution') + ->setFormTypeOption('mapped', false) + ->setVirtual(true) + ->hideOnForm() + ->hideOnDetail() + ->formatValue(fn ($value, ProcessSchedule $entity) => ProcessScheduleType::CRON === $entity->getType() + ? CronExpressionTrigger::fromSpec($entity->getExpression() ?? '') + ->getNextRunDate(new \DateTimeImmutable()) + ?->format('c') + : null), + FormField::addTab('Input'), + TextField::new('input'), + FormField::addTab('Context'), + ArrayField::new('context') + ->setFormTypeOption('entry_type', ProcessContextType::class) + ->hideOnIndex() + ->setFormTypeOption('entry_options.label', 'Context (key/value)') + ->setFormTypeOption('label', '') + ->setFormTypeOption('required', false), + ]; + } + + public function index(AdminContext $context): KeyValueStore|RedirectResponse|Response + { + if (false === $this->schedulerIsRunning()) { + $this->addFlash('warning', 'To run scheduler, ensure "bin/console messenger:consume scheduler_cron" console is alive. See https://symfony.com/doc/current/messenger.html#supervisor-configuration.'); + } + + return parent::index($context); + } + + private function schedulerIsRunning(): bool + { + $process = Process::fromShellCommandline('ps -faux'); + $process->run(); + $out = $process->getOutput(); + + return str_contains($out, 'scheduler_cron'); + } +} diff --git a/src/Controller/Admin/Security/LoginController.php b/src/Controller/Admin/Security/LoginController.php new file mode 100644 index 0000000..ed49f18 --- /dev/null +++ b/src/Controller/Admin/Security/LoginController.php @@ -0,0 +1,33 @@ +render( + '@CleverAgeProcessUi/admin/login.html.twig', + [ + 'page_title' => 'Login', + 'target_path' => '/process', + ] + ); + } +} diff --git a/src/Controller/Admin/Security/LogoutController.php b/src/Controller/Admin/Security/LogoutController.php new file mode 100644 index 0000000..a49cba1 --- /dev/null +++ b/src/Controller/Admin/Security/LogoutController.php @@ -0,0 +1,30 @@ +logout(); + + return $this->redirectToRoute('process_login'); + } +} diff --git a/src/Controller/Admin/UserCrudController.php b/src/Controller/Admin/UserCrudController.php new file mode 100644 index 0000000..45284b3 --- /dev/null +++ b/src/Controller/Admin/UserCrudController.php @@ -0,0 +1,121 @@ + $roles */ + public function __construct(private readonly array $roles) + { + } + + public function configureCrud(Crud $crud): Crud + { + $crud->showEntityActionsInlined(); + $crud->setEntityPermission('ROLE_ADMIN'); + + return $crud; + } + + public static function getEntityFqcn(): string + { + return User::class; + } + + public function configureFields(string $pageName): iterable + { + yield FormField::addTab('Credentials')->setIcon('fa fa-key'); + yield EmailField::new('email'); + yield TextField::new('password', 'New password') + ->onlyOnForms() + ->setFormType(RepeatedType::class) + ->setFormTypeOptions( + [ + 'type' => PasswordType::class, + 'first_options' => [ + 'label' => 'New password', + 'hash_property_path' => 'password', + 'always_empty' => false, + ], + 'second_options' => ['label' => 'Repeat password'], + 'mapped' => false, + ] + ); + yield FormField::addTab('Informations')->setIcon('fa fa-user'); + yield TextField::new('firstname'); + yield TextField::new('lastname'); + + yield FormField::addTab('Roles')->setIcon('fa fa-theater-masks'); + yield ChoiceField::new('roles', false) + ->setChoices($this->roles) + ->setFormTypeOptions(['multiple' => true, 'expanded' => true]); + yield FormField::addTab('Intl.')->setIcon('fa fa-flag'); + yield TimezoneField::new('timezone'); + yield LocaleField::new('locale'); + } + + public function configureActions(Actions $actions): Actions + { + return $actions + ->update(Crud::PAGE_INDEX, Action::NEW, fn (Action $action) => $action->setIcon('fa fa-plus') + ->setLabel(false) + ->addCssClass(''))->update(Crud::PAGE_INDEX, Action::EDIT, fn (Action $action) => $action->setIcon('fa fa-edit') + ->setLabel(false) + ->addCssClass('text-warning'))->update(Crud::PAGE_INDEX, Action::DELETE, fn (Action $action) => $action->setIcon('fa fa-trash-o') + ->setLabel(false) + ->addCssClass(''))->update(Crud::PAGE_INDEX, Action::BATCH_DELETE, fn (Action $action) => $action->setLabel('Delete') + ->addCssClass(''))->add(Crud::PAGE_EDIT, Action::new('generateToken')->linkToCrudAction('generateToken')); + } + + public function generateToken(AdminContext $adminContext, AdminUrlGenerator $adminUrlGenerator): Response + { + /** @var User $user */ + $user = $adminContext->getEntity()->getInstance(); + $token = md5(uniqid(date('YmdHis'))); + $user->setToken((new Pbkdf2PasswordHasher())->hash($token)); + $this->persistEntity( + $this->container->get('doctrine')->getManagerForClass($adminContext->getEntity()->getFqcn()), + $user + ); + $this->addFlash('success', 'New token generated '.$token.' (keep it in secured area. This token will never be displayed anymore)'); + + return $this->redirect( + $adminUrlGenerator + ->setController(self::class) + ->setAction(Action::EDIT) + ->setEntityId($user->getId()) + ->generateUrl() + ); + } +} diff --git a/src/Controller/Crud/ProcessCrudController.php b/src/Controller/Crud/ProcessCrudController.php deleted file mode 100644 index 517e1a0..0000000 --- a/src/Controller/Crud/ProcessCrudController.php +++ /dev/null @@ -1,140 +0,0 @@ -showEntityActionsInlined(); - $crud->setDefaultSort(['lastExecutionDate' => SortOrder::DESC]); - $crud->setEntityPermission('ROLE_ADMIN'); - $crud->setSearchFields(['processCode', 'source', 'target']); - - return $crud; - } - - /** - * @return array - */ - public function configureFields(string $pageName): array - { - return [ - Field::new('processCode', 'Process'), - 'source', - 'target', - 'lastExecutionDate', - IntegerField::new('lastExecutionStatus')->formatValue(static fn (?int $value) => match ($value) { - ProcessExecution::STATUS_FAIL => '', - ProcessExecution::STATUS_START => '', - ProcessExecution::STATUS_SUCCESS => '', - default => '', - }), - ]; - } - - public function configureActions(Actions $actions): Actions - { - $actions->remove(Crud::PAGE_INDEX, Action::EDIT); - $actions->remove(Crud::PAGE_INDEX, Action::DELETE); - $actions->remove(Crud::PAGE_INDEX, Action::NEW); - $runProcess = Action::new('run', '', 'fa fa-rocket') - ->linkToCrudAction('runProcessAction'); - $runProcess->setHtmlAttributes(['data-toggle' => 'tooltip', 'title' => 'Run process in background']); - $runProcess->displayIf(fn (Process $process) => $this->processUiConfigurationManager->canRun($process)); - $viewHistoryAction = Action::new('viewHistory', '', 'fa fa-history') - ->linkToCrudAction('viewHistoryAction'); - $viewHistoryAction->setHtmlAttributes(['data-toggle' => 'tooltip', 'title' => 'View executions history']); - $actions->add(Crud::PAGE_INDEX, $viewHistoryAction); - $actions->add(Crud::PAGE_INDEX, $runProcess); - - return $actions; - } - - public function runProcess(AdminContext $context): Response - { - try { - /** @var Process $process */ - $process = $context->getEntity()->getInstance(); - if (false === $this->processUiConfigurationManager->canRun($process)) { - $this->addFlash( - 'warning', - 'Process is not run-able via Ui.' - ); - } else { - $message = new ProcessRunMessage($process->getProcessCode()); - $this->messageBus->dispatch($message); - $this->addFlash( - 'success', - 'Process has been added to queue. It will start as soon as possible' - ); - } - } catch (\Exception) { - $this->addFlash('warning', 'Cannot run process.'); - } - - return $this->redirect( - $this->adminUrlGenerator->setController(self::class)->setAction(Action::INDEX)->generateUrl() - ); - } - - public function viewHistory(AdminContext $adminContext): RedirectResponse - { - /** @var Process $process */ - $process = $adminContext->getEntity()->getInstance(); - - return $this->redirect( - $this->adminUrlGenerator - ->setController(ProcessExecutionCrudController::class) - ->setEntityId(null) - ->setAction(Action::INDEX) - ->setAll([ - 'filters' => [ - 'processCode' => ['comparison' => ComparisonType::EQ, 'value' => $process->getProcessCode()], - ], - ]) - ->generateUrl() - ); - } -} diff --git a/src/Controller/Crud/ProcessExecutionCrudController.php b/src/Controller/Crud/ProcessExecutionCrudController.php deleted file mode 100644 index 6bd1c10..0000000 --- a/src/Controller/Crud/ProcessExecutionCrudController.php +++ /dev/null @@ -1,132 +0,0 @@ -showEntityActionsInlined(); - $crud->setDefaultSort(['startDate' => SortOrder::DESC]); - $crud->setEntityPermission('ROLE_ADMIN'); - $crud->setSearchFields($this->indexLogs ? ['processCode', 'source', 'target', 'logRecords.message'] : ['processCode', 'source', 'target']); - - return $crud; - } - - /** - * @return array - */ - public function configureFields(string $pageName): array - { - return [ - Field::new('processCode', 'Process'), - 'source', - 'target', - 'startDate', - 'endDate', - IntegerField::new('status')->formatValue(static fn (?int $value) => match ($value) { - ProcessExecution::STATUS_FAIL => '', - ProcessExecution::STATUS_START => '', - ProcessExecution::STATUS_SUCCESS => '', - default => '', - }), - ]; - } - - public function configureFilters(Filters $filters): Filters - { - $processCodeChoices = $this->processUiConfigurationManager->getProcessChoices(); - if ([] !== $processCodeChoices) { - $filters->add(ChoiceFilter::new('processCode', 'Process')->setChoices($processCodeChoices)); - } - - $sourceChoices = $this->processUiConfigurationManager->getSourceChoices(); - if ([] !== $sourceChoices) { - $filters->add(ChoiceFilter::new('source')->setChoices($sourceChoices)); - } - - $targetChoices = $this->processUiConfigurationManager->getTargetChoices(); - if ([] !== $targetChoices) { - $filters->add(ChoiceFilter::new('target')->setChoices($targetChoices)); - } - $filters->add(ChoiceFilter::new('status')->setChoices([ - 'failed' => ProcessExecution::STATUS_FAIL, - 'success' => ProcessExecution::STATUS_SUCCESS, - 'started' => ProcessExecution::STATUS_START, - ])); - $filters->add('startDate'); - $filters->add('endDate'); - - return $filters; - } - - public function configureActions(Actions $actions): Actions - { - $actions->remove(Crud::PAGE_INDEX, Action::EDIT); - $actions->remove(Crud::PAGE_INDEX, Action::DELETE); - $actions->remove(Crud::PAGE_INDEX, Action::NEW); - - $downloadLogAction = Action::new('downloadLog', '', 'fa fa-file-download') - ->linkToCrudAction('downloadLog'); - $downloadLogAction->setHtmlAttributes(['data-toggle' => 'tooltip', 'title' => 'Download log file']); - $actions->add(Crud::PAGE_INDEX, $downloadLogAction); - - return $actions; - } - - public function downloadLog(AdminContext $context): Response - { - /** @var ProcessExecution $processExecution */ - $processExecution = $context->getEntity()->getInstance(); - $filepath = $this->processLogDir.\DIRECTORY_SEPARATOR.$processExecution->getLog(); - $basename = basename($filepath); - $content = file_get_contents($filepath); - if (false === $content) { - throw new NotFoundHttpException('Log file not found.'); - } - $response = new Response($content); - $response->headers->set('Content-Type', 'text/plain; charset=utf-8'); - $response->headers->set('Content-Disposition', "attachment; filename=\"$basename\""); - - return $response; - } -} diff --git a/src/Controller/Crud/UserCrudController.php b/src/Controller/Crud/UserCrudController.php deleted file mode 100644 index 9ee2f36..0000000 --- a/src/Controller/Crud/UserCrudController.php +++ /dev/null @@ -1,134 +0,0 @@ -showEntityActionsInlined(); - $crud->setEntityPermission('ROLE_ADMIN'); - - return $crud; - } - - public static function getEntityFqcn(): string - { - return User::class; - } - - public function configureFields(string $pageName): iterable - { - yield FormField::addPanel('Credentials')->setIcon('fa fa-key'); - yield EmailField::new('email'); - yield TextField::new('password', 'New password') - ->onlyOnForms() - ->setFormType(RepeatedType::class) - ->setFormTypeOptions([ - 'type' => PasswordType::class, - 'first_options' => ['label' => 'New password'], - 'second_options' => ['label' => 'Repeat password'], - ]); - - yield FormField::addPanel('Informations')->setIcon('fa fa-user'); - yield TextField::new('firstname'); - yield TextField::new('lastname'); - - yield FormField::addPanel('Roles')->setIcon('fa fa-theater-masks'); - yield ChoiceField::new('roles', false) - ->setChoices(['ROLE_ADMIN' => 'ROLE_ADMIN', 'ROLE_USER' => 'ROLE_USER']) - ->setFormTypeOptions(['multiple' => true, 'expanded' => true]); - } - - public function configureActions(Actions $actions): Actions - { - return $actions - ->update(Crud::PAGE_INDEX, Action::NEW, fn (Action $action) => $action - ->setIcon(CleverAgeProcessUiBundle::ICON_NEW) - ->setLabel(CleverAgeProcessUiBundle::LABEL_NEW) - ->addCssClass(CleverAgeProcessUiBundle::CLASS_NEW) - )->update(Crud::PAGE_INDEX, Action::EDIT, fn (Action $action) => $action - ->setIcon(CleverAgeProcessUiBundle::ICON_EDIT) - ->setLabel(CleverAgeProcessUiBundle::LABEL_EDIT) - ->addCssClass(CleverAgeProcessUiBundle::CLASS_EDIT) - )->update(Crud::PAGE_INDEX, Action::DELETE, fn (Action $action) => $action - ->setIcon(CleverAgeProcessUiBundle::ICON_DELETE) - ->setLabel(CleverAgeProcessUiBundle::LABEL_DELETE) - ->addCssClass(CleverAgeProcessUiBundle::CLASS_DELETE) - )->update(Crud::PAGE_INDEX, Action::BATCH_DELETE, fn (Action $action) => $action - ->setLabel('Delete') - ->addCssClass(CleverAgeProcessUiBundle::CLASS_DELETE) - ); - } - - public function createEditFormBuilder( - EntityDto $entityDto, - KeyValueStore $formOptions, - AdminContext $context, - ): FormBuilderInterface { - $formBuilder = parent::createEditFormBuilder($entityDto, $formOptions, $context); - - $this->addEncodePasswordEventListener($formBuilder); - - return $formBuilder; - } - - public function createNewFormBuilder( - EntityDto $entityDto, - KeyValueStore $formOptions, - AdminContext $context, - ): FormBuilderInterface { - $formBuilder = parent::createNewFormBuilder($entityDto, $formOptions, $context); - - $this->addEncodePasswordEventListener($formBuilder); - - return $formBuilder; - } - - protected function addEncodePasswordEventListener(FormBuilderInterface $formBuilder): void - { - $formBuilder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event): void { - /** @var User $user */ - $user = $event->getData(); - $password = $user->getPassword(); - if ($password) { - $user->setPassword($this->passwordHasher->hashPassword($user, $password)); - } - }); - } -} diff --git a/src/Controller/DashboardController.php b/src/Controller/DashboardController.php deleted file mode 100644 index 6c4c49b..0000000 --- a/src/Controller/DashboardController.php +++ /dev/null @@ -1,53 +0,0 @@ -container->get(AdminUrlGenerator::class); - - return $this->redirect($routeBuilder->setController(ProcessCrudController::class)->generateUrl()); - } - - public function configureDashboard(): Dashboard - { - return Dashboard::new()->setTitle('CleverAge Process UI'); - } - - public function configureMenuItems(): iterable - { - yield MenuItem::section('Process', 'fas fa-tasks')->setPermission('ROLE_ADMIN'); - yield MenuItem::linkToCrud('List', 'fa fa-list', Process::class)->setPermission('ROLE_ADMIN'); - yield MenuItem::linkToCrud('History', 'fa fa-history', ProcessExecution::class)->setPermission('ROLE_ADMIN'); - - yield MenuItem::section(); - yield MenuItem::section('Settings', 'fas fa-tools')->setPermission('ROLE_ADMIN'); - yield MenuItem::linkToCrud('Users', 'fa fa-users', User::class)->setPermission('ROLE_ADMIN'); - } -} diff --git a/src/Controller/ProcessExecuteController.php b/src/Controller/ProcessExecuteController.php new file mode 100644 index 0000000..a321660 --- /dev/null +++ b/src/Controller/ProcessExecuteController.php @@ -0,0 +1,52 @@ +validate($httpProcessExecution); + if ($violations->count() > 0) { + $violationsMessages = []; + foreach ($violations as $violation) { + $violationsMessages[] = $violation->getMessage(); + } + throw new UnprocessableEntityHttpException(implode('. ', $violationsMessages)); + } + $bus->dispatch( + new ProcessExecuteMessage( + $httpProcessExecution->code ?? '', + $httpProcessExecution->input, + $httpProcessExecution->context + ) + ); + + return new JsonResponse('Process has been added to queue. It will start as soon as possible.'); + } +} diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php deleted file mode 100644 index 819e8ee..0000000 --- a/src/Controller/SecurityController.php +++ /dev/null @@ -1,51 +0,0 @@ -getUser() instanceof \Symfony\Component\Security\Core\User\UserInterface) { - return $this->redirectToRoute('cleverage_ui_process_admin'); - } - - // get the login error if there is one - $error = $authenticationUtils->getLastAuthenticationError(); - // last username entered by the user - $lastUsername = $authenticationUtils->getLastUsername(); - - return $this->render('@EasyAdmin/page/login.html.twig', [ - 'last_username' => $lastUsername, - 'error' => $error, - 'page_title' => 'Login', - 'username_parameter' => 'email', - 'username_label' => 'Email', - 'password_parameter' => 'password', - 'csrf_token_intention' => 'authenticate', - ]); - } - - #[Route('/logout', name: 'cleverage_ui_process_logout')] - public function logout(): void - { - throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.'); - } -} diff --git a/src/DependencyInjection/CleverAgeProcessUiExtension.php b/src/DependencyInjection/CleverAgeProcessUiExtension.php index 65fd2af..d532189 100644 --- a/src/DependencyInjection/CleverAgeProcessUiExtension.php +++ b/src/DependencyInjection/CleverAgeProcessUiExtension.php @@ -13,8 +13,11 @@ namespace CleverAge\ProcessUiBundle\DependencyInjection; -use CleverAge\ProcessUiBundle\Message\LogIndexerMessage; -use CleverAge\ProcessUiBundle\Message\ProcessRunMessage; +use CleverAge\ProcessUiBundle\Controller\Admin\ProcessDashboardController; +use CleverAge\ProcessUiBundle\Controller\Admin\UserCrudController; +use CleverAge\ProcessUiBundle\Entity\User; +use CleverAge\ProcessUiBundle\Message\ProcessExecuteMessage; +use Monolog\Level; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; @@ -22,7 +25,7 @@ use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; use Symfony\Component\Finder\Finder; -class CleverAgeProcessUiExtension extends Extension implements PrependExtensionInterface +final class CleverAgeProcessUiExtension extends Extension implements PrependExtensionInterface { public function load(array $configs, ContainerBuilder $container): void { @@ -30,7 +33,12 @@ public function load(array $configs, ContainerBuilder $container): void $configuration = new Configuration(); $config = $this->processConfiguration($configuration, $configs); - $container->setParameter('clever_age_process_ui.index_logs.enabled', $config['index_logs']['enabled']); + $container->getDefinition(UserCrudController::class) + ->setArgument('$roles', array_combine($config['security']['roles'], $config['security']['roles'])); + $container->getDefinition('cleverage_ui_process.monolog_handler.process') + ->addMethodCall('setReportIncrementLevel', [$config['logs']['report_increment_level']]); + $container->getDefinition(ProcessDashboardController::class) + ->setArgument('$logoPath', $config['design']['logo_path']); } /** @@ -38,6 +46,63 @@ public function load(array $configs, ContainerBuilder $container): void */ public function prepend(ContainerBuilder $container): void { + $env = $container->getParameter('kernel.environment'); + $container->loadFromExtension( + 'monolog', + [ + 'handlers' => [ + 'pb_ui_file' => [ + 'type' => 'service', + 'id' => 'cleverage_ui_process.monolog_handler.process', + ], + 'pb_ui_orm' => [ + 'type' => 'service', + 'id' => 'cleverage_ui_process.monolog_handler.doctrine_process', + ], + ], + ] + ); + if ('dev' === $env) { + $container->loadFromExtension( + 'monolog', + [ + 'handlers' => [ + 'pb_ui_file_filter' => [ + 'type' => 'filter', + 'min_level' => Level::Debug->name, + 'handler' => 'pb_ui_file', + 'channels' => ['cleverage_process', 'cleverage_process_task'], + ], + 'pb_ui_orm_filter' => [ + 'type' => 'filter', + 'min_level' => Level::Debug->name, + 'handler' => 'pb_ui_orm', + 'channels' => ['cleverage_process', 'cleverage_process_task'], + ], + ], + ] + ); + } else { + $container->loadFromExtension( + 'monolog', + [ + 'handlers' => [ + 'pb_ui_file_filter' => [ + 'type' => 'filter', + 'min_level' => Level::Info->name, + 'handler' => 'pb_ui_file', + 'channels' => ['cleverage_process', 'cleverage_process_task'], + ], + 'pb_ui_orm_filter' => [ + 'type' => 'filter', + 'min_level' => Level::Info->name, + 'handler' => 'pb_ui_orm', + 'channels' => ['cleverage_process', 'cleverage_process_task'], + ], + ], + ] + ); + } $container->loadFromExtension( 'doctrine_migrations', [ @@ -47,23 +112,43 @@ public function prepend(ContainerBuilder $container): void $container->loadFromExtension( 'framework', [ - 'assets' => ['json_manifest_path' => null], 'messenger' => [ 'transport' => [ [ - 'name' => 'run_process', - 'dsn' => 'doctrine://default', - 'retry_strategy' => ['max_retries' => 0], - ], - [ - 'name' => 'index_logs', + 'name' => 'execute_process', 'dsn' => 'doctrine://default', 'retry_strategy' => ['max_retries' => 0], ], ], 'routing' => [ - ProcessRunMessage::class => 'run_process', - LogIndexerMessage::class => 'index_logs', + ProcessExecuteMessage::class => 'execute_process', + ], + ], + ] + ); + $container->loadFromExtension( + 'security', + [ + 'providers' => [ + 'process_user_provider' => [ + 'entity' => [ + 'class' => User::class, + 'property' => 'email', + ], + ], + ], + 'firewalls' => [ + 'main' => [ + 'provider' => 'process_user_provider', + 'form_login' => [ + 'login_path' => 'process_login', + 'check_path' => 'process_login', + ], + 'logout' => [ + 'path' => 'process_logout', + 'target' => 'process_login', + 'clear_site_data' => '*', + ], ], ], ] diff --git a/src/DependencyInjection/Compiler/RegisterLogHandlerCompilerPass.php b/src/DependencyInjection/Compiler/RegisterLogHandlerCompilerPass.php deleted file mode 100644 index 8624a45..0000000 --- a/src/DependencyInjection/Compiler/RegisterLogHandlerCompilerPass.php +++ /dev/null @@ -1,37 +0,0 @@ -has($logger)) { - $container - ->getDefinition($logger) - ->addMethodCall('pushHandler', [new Reference('cleverage_ui_process.monolog_handler.process_log')]); - } - } - } -} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index f919fe6..7b9ed08 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -13,6 +13,8 @@ namespace CleverAge\ProcessUiBundle\DependencyInjection; +use Monolog\Level; +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; @@ -20,16 +22,32 @@ class Configuration implements ConfigurationInterface { public function getConfigTreeBuilder(): TreeBuilder { - $treeBuilder = new TreeBuilder('clever_age_process_ui'); - $treeBuilder->getRootNode() + $tb = new TreeBuilder('clever_age_process_ui'); + /** @var ArrayNodeDefinition $rootNode */ + $rootNode = $tb->getRootNode(); + $rootNode ->children() - ->arrayNode('index_logs') - ->ignoreExtraKeys() - ->addDefaultsIfNotSet() + ->arrayNode('security') + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('roles')->defaultValue(['ROLE_ADMIN'])->scalarPrototype()->end(); // Roles displayed inside user edit form + $rootNode + ->children() + ->arrayNode('logs') + ->addDefaultsIfNotSet() ->children() - ->booleanNode('enabled')->defaultFalse()->end() - ?->end(); + ->booleanNode('store_in_database')->defaultValue(true)->end() // enable/disable store log in database (log_record table) + ->scalarNode('database_level')->defaultValue(Level::Debug->name)->end() // min log level to store log record in database + ->scalarNode('report_increment_level')->defaultValue(Level::Warning->name)->end() // min log level to increment process execution report + ->end(); + $rootNode + ->children() + ->arrayNode('design') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('logo_path')->defaultValue('bundles/cleverageprocessui/logo.jpg')->end() + ->end(); - return $treeBuilder; + return $tb; } } diff --git a/src/Entity/Enum/ProcessExecutionStatus.php b/src/Entity/Enum/ProcessExecutionStatus.php new file mode 100644 index 0000000..5d66457 --- /dev/null +++ b/src/Entity/Enum/ProcessExecutionStatus.php @@ -0,0 +1,21 @@ + */ + public readonly array $context; + + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE)] + public readonly \DateTimeImmutable $createdAt; + + public function getId(): ?int + { + return $this->id; + } + + public function __construct(\Monolog\LogRecord $record, #[ORM\ManyToOne(targetEntity: ProcessExecution::class, cascade: ['all'])] + #[ORM\JoinColumn(name: 'process_execution_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + private readonly ProcessExecution $processExecution) + { + $this->channel = (string) (new UnicodeString($record->channel))->truncate(64); + $this->level = $record->level->value; + $this->message = (string) (new UnicodeString($record->message))->truncate(512); + $this->context = $record->context; + $this->createdAt = \DateTimeImmutable::createFromMutable(new \DateTime()); + } + + public function contextIsEmpty(): bool + { + return [] !== $this->context; + } +} diff --git a/src/Entity/Process.php b/src/Entity/Process.php deleted file mode 100644 index d26dd2b..0000000 --- a/src/Entity/Process.php +++ /dev/null @@ -1,105 +0,0 @@ - - */ - #[ORM\OneToMany(targetEntity: ProcessExecution::class, mappedBy: 'process')] - private Collection $executions; - - public function __construct( - #[ORM\Column(name: 'process_code', type: Types::TEXT, length: 255)] - private readonly string $processCode, - - #[ORM\Column(name: 'source', type: Types::TEXT, length: 255, nullable: true)] - private readonly ?string $source = null, - - #[ORM\Column(name: 'target', type: Types::TEXT, length: 255, nullable: true)] - private readonly ?string $target = null, - - #[ORM\Column(name: 'last_execution_date', type: Types::DATETIME_MUTABLE, nullable: true)] - private ?\DateTimeInterface $lastExecutionDate = null, - - #[ORM\Column(name: 'last_execution_status', type: Types::INTEGER, nullable: true)] - private ?int $lastExecutionStatus = null, - ) { - $this->executions = new ArrayCollection(); - } - - public function getId(): ?int - { - return $this->id; - } - - public function getProcessCode(): string - { - return $this->processCode; - } - - public function getSource(): ?string - { - return $this->source; - } - - public function getTarget(): ?string - { - return $this->target; - } - - public function getLastExecutionDate(): ?\DateTimeInterface - { - return $this->lastExecutionDate; - } - - public function getLastExecutionStatus(): ?int - { - return $this->lastExecutionStatus; - } - - /** - * @return Collection - */ - public function getExecutions(): Collection - { - return $this->executions; - } - - public function setLastExecutionDate(\DateTimeInterface $lastExecutionDate): self - { - $this->lastExecutionDate = $lastExecutionDate; - - return $this; - } - - public function setLastExecutionStatus(int $lastExecutionStatus): self - { - $this->lastExecutionStatus = $lastExecutionStatus; - - return $this; - } -} diff --git a/src/Entity/ProcessExecution.php b/src/Entity/ProcessExecution.php index d5a60e9..5a5ffe0 100644 --- a/src/Entity/ProcessExecution.php +++ b/src/Entity/ProcessExecution.php @@ -13,221 +13,103 @@ namespace CleverAge\ProcessUiBundle\Entity; -use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\Common\Collections\Collection; +use CleverAge\ProcessUiBundle\Entity\Enum\ProcessExecutionStatus; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\String\UnicodeString; #[ORM\Entity] -class ProcessExecution +#[ORM\Index(columns: ['code'], name: 'idx_process_execution_code')] +#[ORM\Index(columns: ['start_date'], name: 'idx_process_execution_start_date')] +class ProcessExecution implements \Stringable { - public const STATUS_START = 0; - public const STATUS_SUCCESS = 1; - public const STATUS_FAIL = -1; - #[ORM\Id] - #[ORM\Column(type: Types::INTEGER)] #[ORM\GeneratedValue] + #[ORM\Column] private ?int $id = null; - #[ORM\Column(name: 'process_code', type: Types::STRING, length: 255, nullable: true)] - private ?string $processCode = null; - - #[ORM\Column(type: Types::STRING, length: 255, nullable: true)] - private ?string $source = null; - - #[ORM\Column(type: Types::STRING, length: 255, nullable: true)] - private ?string $target = null; - - #[ORM\Column(type: Types::DATETIME_MUTABLE)] - private \DateTimeInterface $startDate; + #[ORM\Column(type: Types::STRING, length: 255)] + public readonly string $code; - #[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)] - private ?\DateTimeInterface $endDate = null; + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] + public readonly \DateTimeImmutable $startDate; - #[ORM\Column(type: Types::INTEGER)] - private int $status = self::STATUS_START; + #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] + public ?\DateTimeImmutable $endDate = null; - #[ORM\Column(type: Types::STRING, length: 255, nullable: true)] - private ?string $response = null; + #[ORM\Column(type: Types::STRING, enumType: ProcessExecutionStatus::class)] + public ProcessExecutionStatus $status = ProcessExecutionStatus::Started; - #[ORM\Column(type: Types::STRING, length: 255, nullable: true)] - private ?string $data = null; + #[ORM\Column(type: Types::JSON)] + private array $report = []; #[ORM\Column(type: Types::JSON, nullable: true)] - private ?array $report = null; - - #[ORM\Column(type: Types::STRING, length: 255, nullable: true)] - private ?string $log = null; - - /** - * @var Collection - */ - #[ORM\OneToMany(targetEntity: ProcessExecutionLogRecord::class, mappedBy: 'processExecution', cascade: ['persist'])] - private readonly Collection $logRecords; - - public function __construct( - #[ORM\ManyToOne(targetEntity: Process::class, inversedBy: 'executions')] - #[ORM\JoinColumn(name: 'process_id', referencedColumnName: 'id', onDelete: 'SET NULL')] - private readonly Process $process, - ) { - $this->startDate = new \DateTime(); - $this->logRecords = new ArrayCollection(); - } + private ?array $context = []; public function getId(): ?int { return $this->id; } - public function getProcessCode(): ?string + public function __construct(string $code, #[ORM\Column(type: Types::STRING, length: 255)] + public readonly string $logFilename, ?array $context = []) { - return $this->processCode; + $this->code = (string) (new UnicodeString($code))->truncate(255); + $this->startDate = \DateTimeImmutable::createFromMutable(new \DateTime()); + $this->context = $context ?? []; } - public function setProcessCode(?string $processCode): self - { - $this->processCode = $processCode; - - return $this; - } - - public function getSource(): ?string - { - return $this->source; - } - - public function setSource(?string $source): self - { - $this->source = $source; - - return $this; - } - - public function getTarget(): ?string - { - return $this->target; - } - - public function setTarget(?string $target): self - { - $this->target = $target; - - return $this; - } - - public function getStartDate(): \DateTimeInterface - { - return $this->startDate; - } - - public function setStartDate(\DateTimeInterface $startDate): self - { - $this->startDate = $startDate; - - return $this; - } - - public function getEndDate(): ?\DateTimeInterface - { - return $this->endDate; - } - - public function setEndDate(\DateTimeInterface $endDate): self - { - $this->endDate = $endDate; - - return $this; - } - - public function getStatus(): ?int - { - return $this->status; - } - - public function setStatus(int $status): self + public function setStatus(ProcessExecutionStatus $status): void { $this->status = $status; - - return $this; - } - - public function getResponse(): ?string - { - return $this->response; - } - - public function setResponse(?string $response): self - { - $this->response = $response; - - return $this; - } - - public function getData(): ?string - { - return $this->data; } - public function setData(?string $data): self + public function end(): void { - $this->data = $data; - - return $this; + $this->endDate = \DateTimeImmutable::createFromMutable(new \DateTime()); } - public function getLog(): ?string + public function __toString(): string { - return $this->log; + return \sprintf('%s (%s)', $this->id, $this->code); } - public function setLog(string $log): self + public function addReport(string $key, mixed $value): void { - $this->log = $log; - - return $this; + $this->report[$key] = $value; } - public function addLogRecord(ProcessExecutionLogRecord $processExecutionLogRecord): void + public function getReport(?string $key = null, mixed $default = null): mixed { - $processExecutionLogRecord->setProcessExecution($this); - $this->logRecords->add($processExecutionLogRecord); - } + if (null === $key) { + return $this->report; + } - /** - * @return Collection - */ - public function getLogRecords(): Collection - { - return $this->logRecords; + return $this->report[$key] ?? $default; } - /** - * @param Collection $logRecords - */ - public function setLogRecords(Collection $logRecords): self + public function duration(string $format = '%H hour(s) %I min(s) %S s'): ?string { - foreach ($logRecords as $logRecord) { - $this->addLogRecord($logRecord); + if (!$this->endDate instanceof \DateTimeImmutable) { + return null; } + $diff = $this->endDate->diff($this->startDate); - return $this; + return $diff->format($format); } - public function getProcess(): Process + public function getCode(): string { - return $this->process; + return $this->code; } - public function getReport(): array + public function getContext(): ?array { - return $this->report ?? []; + return $this->context; } - public function setReport(?array $report): self + public function setContext(array $context): void { - $this->report = $report; - - return $this; + $this->context = $context; } } diff --git a/src/Entity/ProcessExecutionLogRecord.php b/src/Entity/ProcessExecutionLogRecord.php deleted file mode 100644 index 6c0bee8..0000000 --- a/src/Entity/ProcessExecutionLogRecord.php +++ /dev/null @@ -1,81 +0,0 @@ -id; - } - - public function getLogLevel(): int - { - return $this->logLevel; - } - - public function setLogLevel(int $logLevel): self - { - $this->logLevel = $logLevel; - - return $this; - } - - public function getMessage(): string - { - return $this->message; - } - - public function setMessage(string $message): self - { - $this->message = $message; - - return $this; - } - - public function setProcessExecution(ProcessExecution $processExecution): self - { - $this->processExecution = $processExecution; - - return $this; - } - - public function getProcessExecution(): ?ProcessExecution - { - return $this->processExecution; - } -} diff --git a/src/Entity/ProcessSchedule.php b/src/Entity/ProcessSchedule.php new file mode 100644 index 0000000..5e1cd1b --- /dev/null +++ b/src/Entity/ProcessSchedule.php @@ -0,0 +1,125 @@ +id; + } + + public function getProcess(): ?string + { + return $this->process; + } + + public function setProcess(string $process): static + { + $this->process = $process; + + return $this; + } + + public function getContext(): array + { + return \is_array($this->context) ? $this->context : json_decode($this->context); + } + + public function setContext(array $context): void + { + $this->context = $context; + } + + public function getNextExecution(): null + { + return null; + } + + public function getType(): ProcessScheduleType + { + return $this->type; + } + + public function setType(ProcessScheduleType $type): self + { + $this->type = $type; + + return $this; + } + + public function getExpression(): ?string + { + return $this->expression; + } + + public function setExpression(?string $expression): self + { + $this->expression = $expression; + + return $this; + } + + public function getInput(): ?string + { + return $this->input; + } + + public function setInput(?string $input): self + { + $this->input = $input; + + return $this; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index 07a1943..88982a7 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -13,38 +13,44 @@ namespace CleverAge\ProcessUiBundle\Entity; -use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; #[ORM\Entity] -#[ORM\Table(name: 'user')] +#[ORM\Table(name: 'process_user')] +#[ORM\Index(columns: ['email'], name: 'idx_process_user_email')] class User implements UserInterface, PasswordAuthenticatedUserInterface { #[ORM\Id] #[ORM\GeneratedValue] - #[ORM\Column(type: Types::INTEGER)] + #[ORM\Column] private ?int $id = null; - #[ORM\Column(type: Types::STRING, length: 255, unique: true)] + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255, unique: true)] private ?string $email = null; - #[ORM\Column(type: Types::STRING, length: 255, nullable: true)] + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255, nullable: true)] private ?string $firstname = null; - #[ORM\Column(type: Types::STRING, length: 255, nullable: true)] + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255, nullable: true)] private ?string $lastname = null; - /** - * @var array - */ - #[ORM\Column(type: Types::JSON)] + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON)] private array $roles = []; - #[ORM\Column(type: Types::STRING)] + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255, nullable: true)] private ?string $password = null; + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255, nullable: true)] + private ?string $timezone = null; + + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255, nullable: true)] + private ?string $locale = null; + + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255, nullable: true)] + private ?string $token = null; + public function getId(): ?int { return $this->id; @@ -101,16 +107,36 @@ public function getUsername(): string return $this->getUserIdentifier(); } + public function getTimezone(): ?string + { + return $this->timezone; + } + + public function setTimezone(?string $timezone): self + { + $this->timezone = $timezone; + + return $this; + } + + public function getLocale(): ?string + { + return $this->locale; + } + + public function setLocale(?string $locale): self + { + $this->locale = $locale; + + return $this; + } + /** * @see UserInterface */ public function getRoles(): array { - $roles = $this->roles; - // guarantee every user at least has ROLE_USER - $roles[] = 'ROLE_USER'; - - return array_unique($roles); + return array_merge(['ROLE_USER'], $this->roles); } /** @@ -138,6 +164,18 @@ public function setPassword(string $password): self return $this; } + public function getToken(): ?string + { + return $this->token; + } + + public function setToken(?string $token): self + { + $this->token = $token; + + return $this; + } + /** * Returning a salt is only needed, if you are not using a modern * hashing algorithm (e.g. bcrypt or sodium) in your security.yaml. diff --git a/src/Event/IncrementReportInfoEvent.php b/src/Event/IncrementReportInfoEvent.php deleted file mode 100644 index e16fc24..0000000 --- a/src/Event/IncrementReportInfoEvent.php +++ /dev/null @@ -1,33 +0,0 @@ -key; - } - - public function getProcessCode(): string - { - return $this->processCode; - } -} diff --git a/src/Event/SetReportInfoEvent.php b/src/Event/SetReportInfoEvent.php deleted file mode 100644 index eed2c42..0000000 --- a/src/Event/SetReportInfoEvent.php +++ /dev/null @@ -1,38 +0,0 @@ -key; - } - - public function getValue(): mixed - { - return $this->value; - } - - public function getProcessCode(): string - { - return $this->processCode; - } -} diff --git a/src/EventSubscriber/Crud/ProcessCrudListener.php b/src/EventSubscriber/Crud/ProcessCrudListener.php deleted file mode 100644 index caadd65..0000000 --- a/src/EventSubscriber/Crud/ProcessCrudListener.php +++ /dev/null @@ -1,41 +0,0 @@ - - */ - public static function getSubscribedEvents(): array - { - return [BeforeCrudActionEvent::class => 'syncProcessIntoDatabase']; - } - - public function syncProcessIntoDatabase(BeforeCrudActionEvent $event): void - { - if (Process::class === $event->getAdminContext()?->getEntity()->getFqcn()) { - $this->processRepository->sync(); - } - } -} diff --git a/src/EventSubscriber/ProcessEventSubscriber.php b/src/EventSubscriber/ProcessEventSubscriber.php index 15c51d7..539c656 100644 --- a/src/EventSubscriber/ProcessEventSubscriber.php +++ b/src/EventSubscriber/ProcessEventSubscriber.php @@ -14,143 +14,75 @@ namespace CleverAge\ProcessUiBundle\EventSubscriber; use CleverAge\ProcessBundle\Event\ProcessEvent; +use CleverAge\ProcessUiBundle\Entity\Enum\ProcessExecutionStatus; use CleverAge\ProcessUiBundle\Entity\ProcessExecution; -use CleverAge\ProcessUiBundle\Event\IncrementReportInfoEvent; -use CleverAge\ProcessUiBundle\Event\SetReportInfoEvent; -use CleverAge\ProcessUiBundle\Manager\ProcessUiConfigurationManager; -use CleverAge\ProcessUiBundle\Message\LogIndexerMessage; -use CleverAge\ProcessUiBundle\Monolog\Handler\ProcessLogHandler; -use CleverAge\ProcessUiBundle\Repository\ProcessRepository; -use Doctrine\ORM\EntityManagerInterface; +use CleverAge\ProcessUiBundle\Manager\ProcessExecutionManager; +use CleverAge\ProcessUiBundle\Monolog\Handler\DoctrineProcessHandler; +use CleverAge\ProcessUiBundle\Monolog\Handler\ProcessHandler; use Symfony\Component\EventDispatcher\EventSubscriberInterface; -use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Uid\Uuid; -class ProcessEventSubscriber implements EventSubscriberInterface +final readonly class ProcessEventSubscriber implements EventSubscriberInterface { - private array $processExecution = []; - public function __construct( - private readonly EntityManagerInterface $entityManager, - private readonly ProcessRepository $processRepository, - private readonly ProcessLogHandler $processLogHandler, - private readonly MessageBusInterface $messageBus, - private readonly ProcessUiConfigurationManager $processUiConfigurationManager, - private readonly string $processLogDir, - private readonly bool $indexLogs, + private ProcessHandler $processHandler, + private DoctrineProcessHandler $doctrineProcessHandler, + private ProcessExecutionManager $processExecutionManager, ) { } - public static function getSubscribedEvents(): array - { - return [ - ProcessEvent::EVENT_PROCESS_STARTED => [ - ['syncProcessIntoDatabase', 1000], - ['onProcessStarted', 0], - ], - ProcessEvent::EVENT_PROCESS_ENDED => [ - ['onProcessEnded'], - ], - ProcessEvent::EVENT_PROCESS_FAILED => [ - ['onProcessFailed'], - ], - IncrementReportInfoEvent::NAME => [ - ['updateProcessExecutionReport'], - ], - SetReportInfoEvent::NAME => [ - ['updateProcessExecutionReport'], - ], - ]; - } - - public function onProcessStarted(ProcessEvent $event): void + public function onProcessStart(ProcessEvent $event): void { - $process = $this->processRepository->findOneBy(['processCode' => $event->getProcessCode()]); - if (null === $process) { - throw new \RuntimeException('Unable to found process into database.'); + if (false === $this->processHandler->hasFilename()) { + $this->processHandler->setFilename(\sprintf('%s/%s.log', $event->getProcessCode(), Uuid::v4())); } - $processExecution = new ProcessExecution($process); - $processExecution->setProcessCode($event->getProcessCode()); - $processExecution->setSource($this->processUiConfigurationManager->getSource($event->getProcessCode())); - $processExecution->setTarget($this->processUiConfigurationManager->getTarget($event->getProcessCode())); - $logFilename = \sprintf( - 'process_%s_%s.log', - $event->getProcessCode(), - sha1(uniqid((string) mt_rand(), true)) - ); - $this->processLogHandler->setLogFilename($logFilename, $event->getProcessCode()); - $this->processLogHandler->setCurrentProcessCode($event->getProcessCode()); - $processExecution->setLog($logFilename); - $this->entityManager->persist($processExecution); - $this->entityManager->flush(); - $this->processExecution[$event->getProcessCode()] = $processExecution; - } - - public function onProcessEnded(ProcessEvent $processEvent): void - { - if ($processExecution = ($this->processExecution[$processEvent->getProcessCode()] ?? null)) { - $this->processExecution = array_filter($this->processExecution); - array_pop($this->processExecution); - $this->processLogHandler->setCurrentProcessCode((string) array_key_last($this->processExecution)); - $processExecution->setEndDate(new \DateTime()); - $processExecution->setStatus(ProcessExecution::STATUS_SUCCESS); - $processExecution->getProcess()->setLastExecutionDate($processExecution->getStartDate()); - $processExecution->getProcess()->setLastExecutionStatus( - ProcessExecution::STATUS_SUCCESS + if (!$this->processExecutionManager->getCurrentProcessExecution() instanceof ProcessExecution) { + $processExecution = new ProcessExecution( + $event->getProcessCode(), + basename((string) $this->processHandler->getFilename()), + $event->getProcessContext() ); - $this->entityManager->persist($processExecution); - $this->entityManager->flush(); - $this->dispatchLogIndexerMessage($processExecution); - $this->processExecution[$processEvent->getProcessCode()] = null; + $this->processExecutionManager->setCurrentProcessExecution($processExecution)->save(); } } - public function onProcessFailed(ProcessEvent $processEvent): void + public function success(ProcessEvent $event): void { - if ($processExecution = ($this->processExecution[$processEvent->getProcessCode()] ?? null)) { - $processExecution->setEndDate(new \DateTime()); - $processExecution->setStatus(ProcessExecution::STATUS_FAIL); - $processExecution->getProcess()->setLastExecutionDate($processExecution->getStartDate()); - $processExecution->getProcess()->setLastExecutionStatus(ProcessExecution::STATUS_FAIL); - $this->entityManager->persist($processExecution); - $this->entityManager->flush(); - $this->dispatchLogIndexerMessage($processExecution); - $this->processExecution[$processEvent->getProcessCode()] = null; + if ($event->getProcessCode() === $this->processExecutionManager->getCurrentProcessExecution()?->getCode()) { + $this->processExecutionManager->getCurrentProcessExecution()->setStatus(ProcessExecutionStatus::Finish); + $this->processExecutionManager->getCurrentProcessExecution()->end(); + $this->processExecutionManager->save()->unsetProcessExecution($event->getProcessCode()); + $this->processHandler->close(); } } - public function syncProcessIntoDatabase(): void + public function fail(ProcessEvent $event): void { - $this->processRepository->sync(); + if ($event->getProcessCode() === $this->processExecutionManager->getCurrentProcessExecution()?->getCode()) { + $this->processExecutionManager->getCurrentProcessExecution()->setStatus(ProcessExecutionStatus::Failed); + $this->processExecutionManager->getCurrentProcessExecution()->end(); + $this->processExecutionManager->save()->unsetProcessExecution($event->getProcessCode()); + $this->processHandler->close(); + } } - protected function dispatchLogIndexerMessage(ProcessExecution $processExecution): void + public function flushDoctrineLogs(ProcessEvent $event): void { - if ($this->indexLogs && null !== $processExecutionId = $processExecution->getId()) { - $filePath = $this->processLogDir.\DIRECTORY_SEPARATOR.$processExecution->getLog(); - $file = new \SplFileObject($filePath); - $file->seek(\PHP_INT_MAX); - $chunkSize = LogIndexerMessage::DEFAULT_OFFSET; - $chunk = (int) ($file->key() / $chunkSize) + 1; - for ($i = 0; $i < $chunk; ++$i) { - $this->messageBus->dispatch( - new LogIndexerMessage( - $processExecutionId, - $this->processLogDir.\DIRECTORY_SEPARATOR.$processExecution->getLog(), - $i * $chunkSize - ) - ); - } - } + $this->doctrineProcessHandler->flush(); } - public function updateProcessExecutionReport(IncrementReportInfoEvent|SetReportInfoEvent $event): void + public static function getSubscribedEvents(): array { - if ($processExecution = ($this->processExecution[$event->getProcessCode()] ?? false)) { - $report = $processExecution->getReport(); - $event instanceof IncrementReportInfoEvent - ? $report[$event->getKey()] = ($report[$event->getKey()] ?? 0) + 1 - : $report[$event->getKey()] = $event->getValue(); - $processExecution->setReport($report); - } + return [ + ProcessEvent::EVENT_PROCESS_STARTED => 'onProcessStart', + ProcessEvent::EVENT_PROCESS_ENDED => [ + ['flushDoctrineLogs', 100], + ['success', 100], + ], + ProcessEvent::EVENT_PROCESS_FAILED => [ + ['flushDoctrineLogs', 100], + ['fail', 100], + ], + ]; } } diff --git a/src/Form/Type/LaunchType.php b/src/Form/Type/LaunchType.php new file mode 100644 index 0000000..1cfa6fd --- /dev/null +++ b/src/Form/Type/LaunchType.php @@ -0,0 +1,73 @@ +registry->getProcessConfiguration($code); + $uiOptions = $this->configurationsManager->getUiOptions($code); + $builder->add( + 'input', + 'file' === ($uiOptions['entrypoint_type'] ?? null) ? FileType::class : TextType::class, + [ + 'required' => $configuration->getEntryPoint() instanceof TaskConfiguration, + ] + ); + $builder->add( + 'context', + CollectionType::class, + [ + 'entry_type' => ProcessContextType::class, + 'allow_add' => true, + 'allow_delete' => true, + 'required' => false, + ] + ); + $builder->get('context')->addModelTransformer(new CallbackTransformer( + fn ($data) => $data ?? [], + fn ($data) => array_column($data ?? [], 'value', 'key'), + )); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setRequired('process_code'); + } + + public function getParent(): string + { + return FormType::class; + } +} diff --git a/src/Form/Type/ProcessContextType.php b/src/Form/Type/ProcessContextType.php new file mode 100644 index 0000000..6a7103b --- /dev/null +++ b/src/Form/Type/ProcessContextType.php @@ -0,0 +1,47 @@ +add( + 'key', + null, + [ + 'label' => 'Context Key', + 'attr' => ['placeholder' => 'key'], + 'constraints' => [new NotBlank()], + ] + )->add( + 'value', + null, + [ + 'label' => 'Context Value', + 'attr' => ['placeholder' => 'value'], + 'constraints' => [new NotBlank()], + ] + ); + } + + public function configureOptions(OptionsResolver $resolver): void + { + } +} diff --git a/src/Form/Type/ProcessUploadFileType.php b/src/Form/Type/ProcessUploadFileType.php new file mode 100644 index 0000000..b22026f --- /dev/null +++ b/src/Form/Type/ProcessUploadFileType.php @@ -0,0 +1,31 @@ +setRequired('process_code'); + } + + public function getParent(): string + { + return FileType::class; + } +} diff --git a/src/Http/Model/HttpProcessExecution.php b/src/Http/Model/HttpProcessExecution.php new file mode 100644 index 0000000..c671dba --- /dev/null +++ b/src/Http/Model/HttpProcessExecution.php @@ -0,0 +1,29 @@ +get('input', $request->files->get('input')); + if ($input instanceof UploadedFile) { + $uploadFileName = $this->storageDir.\DIRECTORY_SEPARATOR.date('YmdHis').'_'.uniqid().'_'.$input->getClientOriginalName(); + (new Filesystem())->dumpFile($uploadFileName, $input->getContent()); + $input = $uploadFileName; + } + + return [new HttpProcessExecution($request->get('code'), $input, $request->get('context', []))]; + } +} diff --git a/src/Http/ValueResolver/ProcessConfigurationValueResolver.php b/src/Http/ValueResolver/ProcessConfigurationValueResolver.php new file mode 100644 index 0000000..9c99ebc --- /dev/null +++ b/src/Http/ValueResolver/ProcessConfigurationValueResolver.php @@ -0,0 +1,33 @@ +registry->getProcessConfiguration($request->get('process'))]; + } +} diff --git a/src/Manager/ProcessConfigurationsManager.php b/src/Manager/ProcessConfigurationsManager.php new file mode 100644 index 0000000..5ce8601 --- /dev/null +++ b/src/Manager/ProcessConfigurationsManager.php @@ -0,0 +1,89 @@ +getConfigurations(), fn (ProcessConfiguration $cfg) => $cfg->isPublic()); + } + + /** @return ProcessConfiguration[] */ + public function getPrivateProcesses(): array + { + return array_filter($this->getConfigurations(), fn (ProcessConfiguration $cfg) => !$cfg->isPublic()); + } + + public function getUiOptions(string $processCode): ?array + { + if (false === $this->registry->hasProcessConfiguration($processCode)) { + return null; + } + + $configuration = $this->registry->getProcessConfiguration($processCode); + + return $this->resolveUiOptions($configuration->getOptions())['ui']; + } + + private function resolveUiOptions(array $options): array + { + $resolver = new OptionsResolver(); + $resolver->setDefault('ui', function (OptionsResolver $uiResolver): void { + $uiResolver->setDefaults( + [ + 'source' => null, + 'target' => null, + 'entrypoint_type' => 'text', + 'constraints' => [], + 'run' => null, + 'default' => function (OptionsResolver $defaultResolver) { + $defaultResolver->setDefault('input', null); + $defaultResolver->setDefault('context', function (OptionsResolver $contextResolver) { + $contextResolver->setPrototype(true); + $contextResolver->setRequired(['key', 'value']); + }); + }, + ] + ); + $uiResolver->setDeprecated( + 'run', + 'cleverage/process-ui-bundle', + '2', + 'run ui option is deprecated. Use public option instead to hide a process from UI' + ); + $uiResolver->setAllowedValues('entrypoint_type', ['text', 'file']); + $uiResolver->setNormalizer('constraints', fn (Options $options, array $values): array => (new ConstraintLoader())->buildConstraints($values)); + }); + + return $resolver->resolve($options); + } + + /** @return ProcessConfiguration[] */ + private function getConfigurations(): array + { + return $this->registry->getProcessConfigurations(); + } +} diff --git a/src/Manager/ProcessExecutionManager.php b/src/Manager/ProcessExecutionManager.php new file mode 100644 index 0000000..f342af1 --- /dev/null +++ b/src/Manager/ProcessExecutionManager.php @@ -0,0 +1,71 @@ +currentProcessExecution instanceof ProcessExecution) { + $this->currentProcessExecution = $processExecution; + } + + return $this; + } + + public function getCurrentProcessExecution(): ?ProcessExecution + { + return $this->currentProcessExecution; + } + + public function unsetProcessExecution(string $processCode): self + { + if ($this->currentProcessExecution?->code === $processCode) { + $this->currentProcessExecution = null; + } + + return $this; + } + + public function save(): self + { + if ($this->currentProcessExecution instanceof ProcessExecution) { + $this->processExecutionRepository->save($this->currentProcessExecution); + } + + return $this; + } + + public function increment(string $incrementKey, int $step = 1): void + { + $this->currentProcessExecution?->addReport( + $incrementKey, + $this->currentProcessExecution->getReport($incrementKey, 0) + $step + ); + } + + public function setReport(string $incrementKey, string $value): void + { + $this->currentProcessExecution?->addReport($incrementKey, $value); + } +} diff --git a/src/Manager/ProcessUiConfigurationManager.php b/src/Manager/ProcessUiConfigurationManager.php deleted file mode 100644 index a87d89d..0000000 --- a/src/Manager/ProcessUiConfigurationManager.php +++ /dev/null @@ -1,103 +0,0 @@ - - */ - public function getProcessChoices(): array - { - return array_map(static fn (ProcessConfiguration $configuration) => $configuration->getCode(), $this->processConfigurationRegistry->getProcessConfigurations()); - } - - /** - * @return array - */ - public function getSourceChoices(): array - { - $sources = []; - foreach ($this->processConfigurationRegistry->getProcessConfigurations() as $configuration) { - $source = $this->getSource($configuration->getCode()); - $sources[(string) $source] = (string) $source; - } - - return $sources; - } - - /** - * @return array - */ - public function getTargetChoices(): array - { - $targets = []; - foreach ($this->processConfigurationRegistry->getProcessConfigurations() as $configuration) { - $target = $this->getTarget($configuration->getCode()); - $targets[(string) $target] = (string) $target; - } - - return $targets; - } - - public function getSource(Process|string $process): ?string - { - return $this->resolveUiOptions($process)[self::UI_OPTION_SOURCE]; - } - - public function getTarget(Process|string $process): ?string - { - return $this->resolveUiOptions($process)[self::UI_OPTION_TARGET]; - } - - public function canRun(Process|string $process): bool - { - return (bool) $this->resolveUiOptions($process)[self::UI_OPTION_RUN]; - } - - /** - * @return array - */ - private function resolveUiOptions(Process|string $process): array - { - $code = $process instanceof Process ? $process->getProcessCode() : $process; - $resolver = new OptionsResolver(); - - $resolver->setDefaults([ - self::UI_OPTION_SOURCE => null, - self::UI_OPTION_TARGET => null, - self::UI_OPTION_RUN => true, - ]); - $resolver->setAllowedTypes(self::UI_OPTION_RUN, 'bool'); - $resolver->setAllowedTypes(self::UI_OPTION_SOURCE, ['string', 'null']); - $resolver->setAllowedTypes(self::UI_OPTION_TARGET, ['string', 'null']); - - return $resolver->resolve( - $this->processConfigurationRegistry->getProcessConfiguration($code)->getOptions()['ui_options'] ?? [] - ); - } -} diff --git a/src/Message/CronProcessMessage.php b/src/Message/CronProcessMessage.php new file mode 100644 index 0000000..b5c9d61 --- /dev/null +++ b/src/Message/CronProcessMessage.php @@ -0,0 +1,23 @@ +processSchedule; + $context = array_merge(...array_map(fn ($ctx) => [$ctx['key'] => $ctx['value']], $schedule->getContext())); + $this->bus->dispatch( + new ProcessExecuteMessage($schedule->getProcess() ?? '', $schedule->getInput(), $context) + ); + } +} diff --git a/src/Message/LogIndexerHandler.php b/src/Message/LogIndexerHandler.php deleted file mode 100644 index 7c9f226..0000000 --- a/src/Message/LogIndexerHandler.php +++ /dev/null @@ -1,70 +0,0 @@ -managerRegistry->getManagerForClass(ProcessExecutionLogRecord::class); - $table = $manager->getClassMetadata(ProcessExecutionLogRecord::class)->getTableName(); - $file = new \SplFileObject($logIndexerMessage->getLogPath()); - $file->seek($logIndexerMessage->getStart()); - $offset = $logIndexerMessage->getOffset(); - $parser = new LineLogParser(); - $parameters = []; - while ($offset > 0 && !$file->eof()) { - /** @var string $currentLine */ - $currentLine = $file->current(); - $parsedLine = $parser->parse($currentLine); - if ([] !== $parsedLine && true === ($parsedLine['context'][self::INDEX_LOG_RECORD] ?? false)) { - $parameters[] = $logIndexerMessage->getProcessExecutionId(); - $parameters[] = Logger::toMonologLevel($parsedLine['level']); - $parameters[] = substr((string) $parsedLine['message'], 0, 255); - } - $file->next(); - --$offset; - } - if ([] !== $parameters) { - $statement = $this->getStatement($table, (int) (\count($parameters) / 3)); - $manager->getConnection()->executeStatement($statement, $parameters); - } - } - - private function getStatement(string $table, int $size): string - { - $sql = 'INSERT INTO '.$table.' (process_execution_id, log_level, message) VALUES '; - while ($size > 0) { - $sql .= $size > 1 ? '(?, ?, ?),' : '(?, ?, ?)'; - --$size; - } - - return $sql; - } -} diff --git a/src/Message/LogIndexerMessage.php b/src/Message/LogIndexerMessage.php deleted file mode 100644 index e6a893c..0000000 --- a/src/Message/LogIndexerMessage.php +++ /dev/null @@ -1,47 +0,0 @@ -processExecutionId; - } - - public function getLogPath(): string - { - return $this->logPath; - } - - public function getStart(): int - { - return $this->start; - } - - public function getOffset(): int - { - return $this->offset; - } -} diff --git a/src/Message/ProcessExecuteHandler.php b/src/Message/ProcessExecuteHandler.php new file mode 100644 index 0000000..f786378 --- /dev/null +++ b/src/Message/ProcessExecuteHandler.php @@ -0,0 +1,32 @@ +processHandler->close(); + $this->manager->execute($message->code, $message->input, $message->context); + } +} diff --git a/src/Message/ProcessExecuteMessage.php b/src/Message/ProcessExecuteMessage.php new file mode 100644 index 0000000..5ffdfb3 --- /dev/null +++ b/src/Message/ProcessExecuteMessage.php @@ -0,0 +1,21 @@ +command->run( - new ArrayInput( - [ - 'processCodes' => [$processRunMessage->getProcessCode()], - ] - ), - new NullOutput() - ); - } -} diff --git a/src/Message/ProcessRunMessage.php b/src/Message/ProcessRunMessage.php deleted file mode 100644 index 188e27c..0000000 --- a/src/Message/ProcessRunMessage.php +++ /dev/null @@ -1,37 +0,0 @@ - $processInput - */ - public function __construct(private readonly string $processCode, private readonly array $processInput = []) - { - } - - public function getProcessCode(): string - { - return $this->processCode; - } - - /** - * @return array - */ - public function getProcessInput(): array - { - return $this->processInput; - } -} diff --git a/src/Migrations/Version20210903142035.php b/src/Migrations/Version20210903142035.php deleted file mode 100644 index 3f4e991..0000000 --- a/src/Migrations/Version20210903142035.php +++ /dev/null @@ -1,116 +0,0 @@ -addSql(<<addSql(<<addSql(<<addSql(<<addSql(<<addSql('ALTER TABLE process_execution ADD process_id INT DEFAULT NULL'); - $this->addSql(<<addSql('CREATE INDEX IDX_98E995D27EC2F574 ON process_execution (process_id)'); - } - - public function down(Schema $schema): void - { - // this down() migration is auto-generated, please modify it to your needs - $this->addSql('DROP TABLE process'); - $this->addSql('DROP TABLE process_execution'); - $this->addSql('DROP TABLE process_execution_log_record'); - $this->addSql('DROP TABLE user'); - } - - public function isTransactional(): bool - { - return false; - } -} diff --git a/src/Migrations/Version20231006111525.php b/src/Migrations/Version20231006111525.php new file mode 100644 index 0000000..03d4d55 --- /dev/null +++ b/src/Migrations/Version20231006111525.php @@ -0,0 +1,80 @@ +connection->getDatabasePlatform(); + + if ($platform instanceof MariaDBPlatform || $platform instanceof MySQLPlatform) { + if (!$schema->hasTable('log_record')) { + $this->addSql('CREATE TABLE log_record (id INT AUTO_INCREMENT NOT NULL, process_execution_id INT DEFAULT NULL, channel VARCHAR(64) NOT NULL, level INT NOT NULL, message VARCHAR(512) NOT NULL, context JSON NOT NULL COMMENT \'(DC2Type:json)\', created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_8ECECC333DAC0075 (process_execution_id), INDEX idx_log_record_level (level), INDEX idx_log_record_created_at (created_at), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + } + if (!$schema->hasTable('process_execution')) { + $this->addSql('CREATE TABLE process_execution (id INT AUTO_INCREMENT NOT NULL, code VARCHAR(255) NOT NULL, log_filename VARCHAR(255) NOT NULL, start_date DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', end_date DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', status VARCHAR(255) NOT NULL, report JSON NOT NULL COMMENT \'(DC2Type:json)\', INDEX idx_process_execution_code (code), INDEX idx_process_execution_start_date (start_date), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE log_record ADD CONSTRAINT FK_8ECECC333DAC0075 FOREIGN KEY (process_execution_id) REFERENCES process_execution (id) ON DELETE CASCADE'); + } + if (!$schema->hasTable('process_user')) { + $this->addSql('CREATE TABLE process_user (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(255) NOT NULL, firstname VARCHAR(255) DEFAULT NULL, lastname VARCHAR(255) DEFAULT NULL, roles JSON NOT NULL COMMENT \'(DC2Type:json)\', password VARCHAR(255) DEFAULT NULL, UNIQUE INDEX UNIQ_627A047CE7927C74 (email), INDEX idx_process_user_email (email), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + } + } + + if ($platform instanceof PostgreSQLPlatform) { + if (!$schema->hasTable('log_record')) { + $this->addSql('CREATE SEQUENCE log_record_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE log_record (id INT NOT NULL, process_execution_id INT DEFAULT NULL, channel VARCHAR(64) NOT NULL, level INT NOT NULL, message VARCHAR(512) NOT NULL, context JSON NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_8ECECC333DAC0075 ON log_record (process_execution_id)'); + $this->addSql('CREATE INDEX idx_log_record_level ON log_record (level)'); + $this->addSql('CREATE INDEX idx_log_record_created_at ON log_record (created_at)'); + $this->addSql('COMMENT ON COLUMN log_record.created_at IS \'(DC2Type:datetime_immutable)\''); + } + if (!$schema->hasTable('process_execution')) { + $this->addSql('CREATE SEQUENCE process_execution_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE process_execution (id INT NOT NULL, code VARCHAR(255) NOT NULL, log_filename VARCHAR(255) NOT NULL, start_date TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, end_date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, status VARCHAR(255) NOT NULL, report JSON NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX idx_process_execution_code ON process_execution (code)'); + $this->addSql('CREATE INDEX idx_process_execution_start_date ON process_execution (start_date)'); + $this->addSql('COMMENT ON COLUMN process_execution.start_date IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN process_execution.end_date IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE log_record ADD CONSTRAINT FK_8ECECC333DAC0075 FOREIGN KEY (process_execution_id) REFERENCES process_execution (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + if (!$schema->hasTable('process_user')) { + $this->addSql('CREATE SEQUENCE process_user_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE process_user (id INT NOT NULL, email VARCHAR(255) NOT NULL, firstname VARCHAR(255) DEFAULT NULL, lastname VARCHAR(255) DEFAULT NULL, roles JSON NOT NULL, password VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_627A047CE7927C74 ON process_user (email)'); + $this->addSql('CREATE INDEX idx_process_user_email ON process_user (email)'); + } + } + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE log_record DROP CONSTRAINT FK_8ECECC333DAC0075'); + $this->addSql('DROP TABLE log_record'); + $this->addSql('DROP TABLE process_execution'); + $this->addSql('DROP TABLE process_user'); + } +} diff --git a/src/Migrations/Version20240729151928.php b/src/Migrations/Version20240729151928.php new file mode 100644 index 0000000..dfdc762 --- /dev/null +++ b/src/Migrations/Version20240729151928.php @@ -0,0 +1,48 @@ +connection->getDatabasePlatform(); + if ($platform instanceof PostgreSQLPlatform) { + $this->addSql('CREATE TABLE process_schedule (id INT AUTO_INCREMENT NOT NULL, process VARCHAR(255) NOT NULL, type VARCHAR(6) NOT NULL, expression VARCHAR(255) NOT NULL, input VARCHAR(255), context JSON NOT NULL, PRIMARY KEY(id))'); + } + + if ($platform instanceof MariaDBPlatform || $platform instanceof MySQLPlatform) { + $this->addSql('CREATE TABLE process_schedule (id INT AUTO_INCREMENT NOT NULL, process VARCHAR(255) NOT NULL, type VARCHAR(6) NOT NULL, expression VARCHAR(255) NOT NULL, input VARCHAR(255), context JSON NOT NULL COMMENT \'(DC2Type:json)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB;'); + } + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE process_schedule'); + } +} diff --git a/src/Migrations/Version20211028081845.php b/src/Migrations/Version20240730090403.php similarity index 57% rename from src/Migrations/Version20211028081845.php rename to src/Migrations/Version20240730090403.php index fcd13af..2f6ca6e 100644 --- a/src/Migrations/Version20211028081845.php +++ b/src/Migrations/Version20240730090403.php @@ -19,27 +19,20 @@ /** * Auto-generated Migration: Please modify to your needs! */ -final class Version20211028081845 extends AbstractMigration +final class Version20240730090403 extends AbstractMigration { public function getDescription(): string { - return ''; + return 'Add process_user.token'; } public function up(Schema $schema): void { - // this up() migration is auto-generated, please modify it to your needs - $this->addSql('ALTER TABLE process_execution ADD report JSON DEFAULT NULL'); + $this->addSql('ALTER TABLE process_user ADD token VARCHAR(255) DEFAULT NULL'); } public function down(Schema $schema): void { - // this down() migration is auto-generated, please modify it to your needs - $this->addSql('ALTER TABLE process_execution DROP report'); - } - - public function isTransactional(): bool - { - return false; + $this->addSql('ALTER TABLE process_user DROP token'); } } diff --git a/src/Migrations/Version20241007134542.php b/src/Migrations/Version20241007134542.php new file mode 100644 index 0000000..403c538 --- /dev/null +++ b/src/Migrations/Version20241007134542.php @@ -0,0 +1,42 @@ +hasTable('process_user') && !$schema->getTable('process_user')->hasColumn('timezone')) { + $this->addSql('ALTER TABLE process_user ADD timezone VARCHAR(255) DEFAULT NULL'); + } + } + + public function down(Schema $schema): void + { + if ($schema->hasTable('process_user') && $schema->getTable('process_user')->hasColumn('timezone')) { + $this->addSql('ALTER TABLE process_user DROP timezone'); + } + } +} diff --git a/src/Migrations/Version20241007152613.php b/src/Migrations/Version20241007152613.php new file mode 100644 index 0000000..8f989d7 --- /dev/null +++ b/src/Migrations/Version20241007152613.php @@ -0,0 +1,42 @@ +hasTable('process_execution') && !$schema->getTable('process_execution')->hasColumn('context')) { + $this->addSql('ALTER TABLE process_execution ADD context JSON NOT NULL'); + } + } + + public function down(Schema $schema): void + { + if ($schema->hasTable('process_execution') && $schema->getTable('process_execution')->hasColumn('context')) { + $this->addSql('ALTER TABLE process_execution DROP context'); + } + } +} diff --git a/src/Migrations/Version20241009075733.php b/src/Migrations/Version20241009075733.php new file mode 100644 index 0000000..6a027e2 --- /dev/null +++ b/src/Migrations/Version20241009075733.php @@ -0,0 +1,39 @@ +hasTable('process_user') && !$schema->getTable('process_user')->hasColumn('locale')) { + $this->addSql('ALTER TABLE process_user ADD locale VARCHAR(255) DEFAULT NULL'); + } + } + + public function down(Schema $schema): void + { + if ($schema->hasTable('process_user') && $schema->getTable('process_user')->hasColumn('locale')) { + $this->addSql('ALTER TABLE process_user DROP locale'); + } + } +} diff --git a/src/Monolog/Handler/DoctrineProcessHandler.php b/src/Monolog/Handler/DoctrineProcessHandler.php new file mode 100644 index 0000000..1595694 --- /dev/null +++ b/src/Monolog/Handler/DoctrineProcessHandler.php @@ -0,0 +1,75 @@ + */ + private ArrayCollection $records; + private ?ProcessExecutionManager $processExecutionManager = null; + private ?EntityManagerInterface $em = null; + + public function __construct(int|string|Level $level = Level::Debug, bool $bubble = true) + { + parent::__construct($level, $bubble); + $this->records = new ArrayCollection(); + } + + public function setEntityManager(EntityManagerInterface $em): void + { + $this->em = $em; + } + + public function setProcessExecutionManager(ProcessExecutionManager $processExecutionManager): void + { + $this->processExecutionManager = $processExecutionManager; + } + + public function __destruct() + { + $this->flush(); + parent::__destruct(); + } + + public function flush(): void + { + foreach ($this->records as $record) { + if (($currentProcessExecution = $this->processExecutionManager?->getCurrentProcessExecution()) instanceof ProcessExecution) { + $entity = new \CleverAge\ProcessUiBundle\Entity\LogRecord($record, $currentProcessExecution); + $this->em?->persist($entity); + } + } + $this->em?->flush(); + foreach ($this->records as $record) { + $this->em?->detach($record); + } + $this->records = new ArrayCollection(); + } + + protected function write(LogRecord $record): void + { + $this->records->add($record); + if (500 === $this->records->count()) { + $this->flush(); + } + } +} diff --git a/src/Monolog/Handler/ProcessHandler.php b/src/Monolog/Handler/ProcessHandler.php new file mode 100644 index 0000000..be51136 --- /dev/null +++ b/src/Monolog/Handler/ProcessHandler.php @@ -0,0 +1,68 @@ +directory); + } + + /** + * @param 'ALERT'|'Alert'|'alert'|'CRITICAL'|'Critical'|'critical'|'DEBUG'|'Debug'|'debug'|'EMERGENCY'|'Emergency'|'emergency'|'ERROR'|'Error'|'error'|'INFO'|'Info'|'info'|'NOTICE'|'Notice'|'notice'|'WARNING'|'Warning'|'warning' $level + */ + public function setReportIncrementLevel(string $level): void + { + $this->reportIncrementLevel = Level::fromName($level); + } + + public function hasFilename(): bool + { + return $this->directory !== $this->url; + } + + public function setFilename(string $filename): void + { + $this->url = \sprintf('%s/%s', $this->directory, $filename); + } + + public function close(): void + { + $this->url = $this->directory; + parent::close(); + } + + public function getFilename(): ?string + { + return $this->url; + } + + public function write(LogRecord $record): void + { + parent::write($record); + if ($record->level->value >= $this->reportIncrementLevel->value) { + $this->processExecutionManager->increment($record->level->name); + } + } +} diff --git a/src/Monolog/Handler/ProcessLogHandler.php b/src/Monolog/Handler/ProcessLogHandler.php deleted file mode 100644 index 131bc39..0000000 --- a/src/Monolog/Handler/ProcessLogHandler.php +++ /dev/null @@ -1,71 +0,0 @@ - $record - * - * @throws FilesystemException - */ - protected function write(array|LogRecord $record): void - { - if (null === $logFilename = ($this->logFilenames[$this->currentProcessCode] ?? null)) { - return; - } - - if ($record['level'] < Level::Info) { - return; - } - - if (!$this->filesystem instanceof Filesystem) { - $this->filesystem = new Filesystem( - new LocalFilesystemAdapter($this->processLogDir, null, \FILE_APPEND) - ); - } - $this->filesystem->write($logFilename, $record['formatted']); - } - - public function setLogFilename(string $logFilename, string $processCode): void - { - $this->logFilenames[$processCode] = $logFilename; - } - - public function setCurrentProcessCode(?string $code): void - { - $this->currentProcessCode = $code; - } - - public function getLogFilename(): ?string - { - return $this->logFilenames[$this->currentProcessCode] ?? null; - } -} diff --git a/src/Repository/.gitignore b/src/Repository/.gitignore deleted file mode 100644 index e69de29..0000000 diff --git a/src/Repository/ProcessExecutionRepository.php b/src/Repository/ProcessExecutionRepository.php index 3a52f65..ef2bc80 100644 --- a/src/Repository/ProcessExecutionRepository.php +++ b/src/Repository/ProcessExecutionRepository.php @@ -19,6 +19,11 @@ /** * @extends EntityRepository + * + * @method ProcessExecution|null find($id, $lockMode = null, $lockVersion = null) + * @method ProcessExecution|null findOneBy(array $criteria, array $orderBy = null) + * @method ProcessExecution[] findAll() + * @method ProcessExecution[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ class ProcessExecutionRepository extends EntityRepository { @@ -27,61 +32,21 @@ public function __construct(EntityManagerInterface $em) parent::__construct($em, $em->getClassMetadata(ProcessExecution::class)); } - /** - * @return array - */ - public function getProcessCodeChoices(): array + public function save(ProcessExecution $processExecution): void { - $choices = []; - $qb = $this->createQueryBuilder('pe'); - $qb->distinct(true); - $qb->select('pe.processCode'); - foreach ($qb->getQuery()->getArrayResult() as $result) { - $choices[(string) $result['processCode']] = (string) $result['processCode']; - } - - return $choices; - } - - /** - * @return array - */ - public function getSourceChoices(): array - { - $choices = []; - $qb = $this->createQueryBuilder('pe'); - $qb->distinct(true); - $qb->select('pe.source'); - foreach ($qb->getQuery()->getArrayResult() as $result) { - $choices[(string) $result['source']] = (string) $result['source']; - } - - return $choices; - } - - /** - * @return array - */ - public function getTargetChoices(): array - { - $choices = []; - $qb = $this->createQueryBuilder('pe'); - $qb->distinct(true); - $qb->select('pe.target'); - foreach ($qb->getQuery()->getArrayResult() as $result) { - $choices[(string) $result['target']] = (string) $result['target']; - } - - return $choices; + $this->getEntityManager()->persist($processExecution); + $this->getEntityManager()->flush(); } - public function deleteBefore(\DateTime $dateTime): void + public function getLastProcessExecution(string $code): ?ProcessExecution { $qb = $this->createQueryBuilder('pe'); - $qb->delete(); - $qb->where('pe.startDate < :date'); - $qb->setParameter('date', $dateTime); - $qb->getQuery()->execute(); + return $qb->select('pe') + ->where($qb->expr()->eq('pe.code', $qb->expr()->literal($code))) + ->orderBy('pe.startDate', 'DESC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); } } diff --git a/src/Repository/ProcessRepository.php b/src/Repository/ProcessRepository.php deleted file mode 100644 index 7cbaa25..0000000 --- a/src/Repository/ProcessRepository.php +++ /dev/null @@ -1,59 +0,0 @@ - - */ -class ProcessRepository extends EntityRepository -{ - public function __construct( - EntityManagerInterface $em, - private readonly ProcessUiConfigurationManager $processUiConfigurationManager, - private readonly ProcessConfigurationRegistry $processConfigurationRegistry, - ) { - parent::__construct($em, $em->getClassMetadata(Process::class)); - } - - public function sync(): void - { - // Create missing process into database - $codes = []; - foreach ($this->processConfigurationRegistry->getProcessConfigurations() as $configuration) { - $process = $this->findOneBy(['processCode' => $configuration->getCode()]); - $codes[] = $configuration->getCode(); - if (null === $process) { - $process = new Process( - $configuration->getCode(), - $this->processUiConfigurationManager->getSource($configuration->getCode()), - $this->processUiConfigurationManager->getTarget($configuration->getCode()), - ); - $this->getEntityManager()->persist($process); - } - } - $this->getEntityManager()->flush(); - - // Delete process in database if not into configuration registry - $qb = $this->createQueryBuilder('p'); - $qb->delete(); - $qb->where($qb->expr()->notIn('p.processCode', $codes)); - $qb->getQuery()->execute(); - } -} diff --git a/src/Repository/ProcessScheduleRepository.php b/src/Repository/ProcessScheduleRepository.php new file mode 100644 index 0000000..2a40866 --- /dev/null +++ b/src/Repository/ProcessScheduleRepository.php @@ -0,0 +1,34 @@ + + * + * @method ProcessSchedule|null find($id, $lockMode = null, $lockVersion = null) + * @method ProcessSchedule|null findOneBy(array $criteria, array $orderBy = null) + * @method ProcessSchedule[] findAll() + * @method ProcessSchedule[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class ProcessScheduleRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ProcessSchedule::class); + } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php deleted file mode 100644 index d9ed2db..0000000 --- a/src/Repository/UserRepository.php +++ /dev/null @@ -1,45 +0,0 @@ - - */ -class UserRepository extends EntityRepository implements PasswordUpgraderInterface -{ - public function __construct(EntityManagerInterface $em) - { - parent::__construct($em, $em->getClassMetadata(User::class)); - } - - /** - * Used to upgrade (rehash) the user's password automatically over time. - */ - public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void - { - if (!$user instanceof User) { - throw new UnsupportedUserException(\sprintf('Instances of "%s" are not supported.', $user::class)); - } - $user->setPassword($newHashedPassword); - $this->getEntityManager()->persist($user); - $this->getEntityManager()->flush(); - } -} diff --git a/src/Scheduler/CronScheduler.php b/src/Scheduler/CronScheduler.php new file mode 100644 index 0000000..6f0de44 --- /dev/null +++ b/src/Scheduler/CronScheduler.php @@ -0,0 +1,75 @@ +repository->findAll() as $processSchedule) { + $violations = $this->validator->validate($processSchedule); + if (0 !== $violations->count()) { + foreach ($violations as $violation) { + $this->logger->info( + 'Scheduler configuration is not valid.', + ['reason' => $violation->getMessage()] + ); + } + continue; + } + if (ProcessScheduleType::CRON === $processSchedule->getType()) { + $schedule->add( + RecurringMessage::cron( + $processSchedule->getExpression() ?? '', + new CronProcessMessage($processSchedule) + ) + ); + } elseif (ProcessScheduleType::EVERY === $processSchedule->getType()) { + $schedule->add( + RecurringMessage::every( + $processSchedule->getExpression() ?? '', + new CronProcessMessage($processSchedule) + ) + ); + } + } + } catch (\Exception $e) { + $this->logger->critical($e->getMessage()); + } + + return $schedule; + } +} diff --git a/src/Security/HttpProcessExecutionAuthenticator.php b/src/Security/HttpProcessExecutionAuthenticator.php new file mode 100644 index 0000000..2b28680 --- /dev/null +++ b/src/Security/HttpProcessExecutionAuthenticator.php @@ -0,0 +1,70 @@ +get('_route') && $request->isMethod(Request::METHOD_POST); + } + + public function authenticate(Request $request): Passport + { + if (false === $request->headers->has('Authorization')) { + throw new AuthenticationException('Missing auth token.'); + } + $token = $request->headers->get('Authorization'); + $token = str_replace('Bearer ', '', $token ?? ''); + $user = $this->entityManager->getRepository(User::class)->findOneBy( + ['token' => (new Pbkdf2PasswordHasher())->hash($token)] + ); + if (null === $user) { + throw new AuthenticationException('Invalid token.'); + } + + return new SelfValidatingPassport(new UserBadge($user->getEmail() ?? '')); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + $data = [ + 'message' => $exception->getMessage(), + ]; + + return new JsonResponse($data, Response::HTTP_UNAUTHORIZED); + } +} diff --git a/src/Security/LoginFormAuthAuthenticator.php b/src/Security/LoginFormAuthAuthenticator.php deleted file mode 100644 index 6898e8c..0000000 --- a/src/Security/LoginFormAuthAuthenticator.php +++ /dev/null @@ -1,67 +0,0 @@ -request->get('email', ''); - - $request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $username); - - return new Passport( - new UserBadge($username), - new PasswordCredentials((string) $request->request->get('password', '')), - [ - new CsrfTokenBadge('authenticate', $request->get('_csrf_token')), - ] - ); - } - - public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response - { - if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) { - return new RedirectResponse($targetPath); - } - - return new RedirectResponse($this->urlGenerator->generate('cleverage_ui_process_admin')); - } - - protected function getLoginUrl(Request $request): string - { - return $this->urlGenerator->generate(self::LOGIN_ROUTE); - } -} diff --git a/src/Twig/Extension/LogLevelExtension.php b/src/Twig/Extension/LogLevelExtension.php new file mode 100644 index 0000000..32339dc --- /dev/null +++ b/src/Twig/Extension/LogLevelExtension.php @@ -0,0 +1,29 @@ +getName(); + } + + public function getCssClass(string|int $value): string + { + return \is_int($value) ? + match ($value) { + Level::Warning->value => 'warning', + Level::Error->value, Level::Emergency->value, Level::Critical->value, Level::Alert->value => 'danger', + Level::Debug->value, Level::Info->value => 'success', + default => '', + } + : match ($value) { + Level::Warning->name => 'warning', + Level::Error->name, Level::Emergency->name, Level::Critical->name, Level::Alert->name => 'danger', + Level::Debug->name, Level::Info->name => 'success', + default => '', + }; + } +} diff --git a/src/Twig/Runtime/MD5ExtensionRuntime.php b/src/Twig/Runtime/MD5ExtensionRuntime.php new file mode 100644 index 0000000..bd8263e --- /dev/null +++ b/src/Twig/Runtime/MD5ExtensionRuntime.php @@ -0,0 +1,24 @@ +processExecutionRepository->getLastProcessExecution($code); + } + + public function getProcessSource(string $code): ?string + { + return $this->processConfigurationsManager->getUiOptions($code)['source'] ?? null; + } + + public function getProcessTarget(string $code): ?string + { + return $this->processConfigurationsManager->getUiOptions($code)['target'] ?? null; + } +} diff --git a/src/Validator/CronExpression.php b/src/Validator/CronExpression.php new file mode 100644 index 0000000..9c5e07e --- /dev/null +++ b/src/Validator/CronExpression.php @@ -0,0 +1,22 @@ +context->buildViolation($constraint->message) + ->setParameter('{{ value }}', $value) + ->addViolation(); + } + } +} diff --git a/src/Validator/EveryExpression.php b/src/Validator/EveryExpression.php new file mode 100644 index 0000000..1423628 --- /dev/null +++ b/src/Validator/EveryExpression.php @@ -0,0 +1,22 @@ +context->buildViolation($constraint->message) + ->setParameter('{{ value }}', $value) + ->addViolation(); + } +} diff --git a/src/Validator/IsValidProcessCode.php b/src/Validator/IsValidProcessCode.php new file mode 100644 index 0000000..f33c133 --- /dev/null +++ b/src/Validator/IsValidProcessCode.php @@ -0,0 +1,23 @@ +registry->hasProcessConfiguration($value)) { + $this->context->buildViolation($constraint->messageNotExists) + ->setParameter('{{ value }}', $value) + ->addViolation(); + + return; + } + + if (!$this->registry->getProcessConfiguration($value)->isPublic()) { + $this->context->buildViolation($constraint->messageIsNotPublic) + ->setParameter('{{ value }}', $value) + ->addViolation(); + } + } +} diff --git a/templates/admin/field/array.html.twig b/templates/admin/field/array.html.twig new file mode 100644 index 0000000..8cdf10d --- /dev/null +++ b/templates/admin/field/array.html.twig @@ -0,0 +1,16 @@ +{# @var ea \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #} +{# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #} +{# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #} +
    + {{ _self.render(field.value) }} +
+ +{% macro render(value) %} + {% for key, item in value %} + {% if item is iterable %} +
  • {{ key }}
    • {{ _self.render(item) }}
    + {% else %} +
  • {{ key }} : {{ item }}
  • + {% endif %} + {% endfor %} +{% endmacro %} diff --git a/templates/admin/field/enum.html.twig b/templates/admin/field/enum.html.twig new file mode 100644 index 0000000..e615276 --- /dev/null +++ b/templates/admin/field/enum.html.twig @@ -0,0 +1,2 @@ +{% set class = field.value.value == 'failed' ? 'danger' : 'success' %} +{{ field.value.value }} diff --git a/templates/admin/field/log_level.html.twig b/templates/admin/field/log_level.html.twig new file mode 100644 index 0000000..2a666ad --- /dev/null +++ b/templates/admin/field/log_level.html.twig @@ -0,0 +1 @@ +{{ log_label(field.value) }} diff --git a/templates/admin/field/process_source.html.twig b/templates/admin/field/process_source.html.twig new file mode 100644 index 0000000..fea3203 --- /dev/null +++ b/templates/admin/field/process_source.html.twig @@ -0,0 +1 @@ +{{ get_process_source(entity.instance.code) }} diff --git a/templates/admin/field/process_target.html.twig b/templates/admin/field/process_target.html.twig new file mode 100644 index 0000000..15470e6 --- /dev/null +++ b/templates/admin/field/process_target.html.twig @@ -0,0 +1 @@ +{{ get_process_target(entity.instance.code) }} diff --git a/templates/admin/field/report.html.twig b/templates/admin/field/report.html.twig new file mode 100644 index 0000000..c24b268 --- /dev/null +++ b/templates/admin/field/report.html.twig @@ -0,0 +1,8 @@ +{# @var ea \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #} +{# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #} +{# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #} +
      + {% for key, item in field.value %} +
    • {{ key }} : {{ item }}
    • + {% endfor %} +
    diff --git a/templates/admin/login.html.twig b/templates/admin/login.html.twig new file mode 100644 index 0000000..1d1d077 --- /dev/null +++ b/templates/admin/login.html.twig @@ -0,0 +1 @@ +{% extends '@EasyAdmin/page/login.html.twig' %} diff --git a/templates/admin/process/launch.html.twig b/templates/admin/process/launch.html.twig new file mode 100644 index 0000000..88d0f7b --- /dev/null +++ b/templates/admin/process/launch.html.twig @@ -0,0 +1,18 @@ +{% extends ea.templatePath('layout') %} +{% trans_default_domain ea.i18n.translationDomain %} + +{% block main %} + {% form_theme form '@EasyAdmin/crud/form_theme.html.twig' %} +
    +
    +
    + {{ form_start(form) }} + {{ form_widget(form) }} +
    + +
    + {{ form_end(form) }} +
    +
    +
    +{% endblock %} diff --git a/templates/admin/process/list.html.twig b/templates/admin/process/list.html.twig new file mode 100644 index 0000000..ac6d384 --- /dev/null +++ b/templates/admin/process/list.html.twig @@ -0,0 +1,65 @@ +{# @var urlGenerator \EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator #} +{% extends ea.templatePath('layout') %} +{% trans_default_domain ea.i18n.translationDomain %} + +{% block main %} + + + {% block table_head %} + + + + + + + + + {% endblock %} + + + {% block table_body %} + {# @var process \CleverAge\ProcessBundle\Configuration\ProcessConfiguration #} + {% for process in processes %} + {% set lastExecution = get_last_execution_date(process.code) %} + {% set statusClass = '' %} + {% if lastExecution is not null %} + {% set statusClass = lastExecution.status.value == 'failed' ? 'danger' : 'success' %} + {% endif %} + + + + + + + + + {% endfor %} + {% endblock %} + +
    {{ 'Process code'|trans }}{{ 'Last execution'|trans }}{{ 'Status'|trans }}{{ 'Source'|trans }}{{ 'Target'|trans }}{{ 'Actions'|trans }}
    {{ process.code }}{% if lastExecution is not null %}{{ lastExecution.startDate|date('Y/m/d H:i:s') }}{% endif %}{% if lastExecution is not null %}{{ lastExecution.status.value }}{% endif %}{% if process.options.ui.source is defined %}{{ process.options.ui.source }}{% endif %}{% if process.options.ui.target is defined %}{{ process.options.ui.target }}{% endif %} + + + + + + +
    +{% endblock %} diff --git a/translations/messages.fr.yaml b/translations/messages.fr.yaml new file mode 100644 index 0000000..4d276e3 --- /dev/null +++ b/translations/messages.fr.yaml @@ -0,0 +1,54 @@ +Generate Token: Générer un jeton +Credentials: Informations d'identification +User: Utilisateur +Dashboard: Tableau de bord +Process: Processus +Process list: Liste des processus +Executions: Exécutions +Logs: Logs +Scheduler: Planificateur +Users: Utilisateurs +User List: Liste des utilisateurs +Informations: Informations +Roles: Rôles +Intl.: Internationalisation +Email: Email +New password: Nouveau mot de passe +Repeat password: Répéter le mot de passe +Firstname: Prénom +Lastname: Nom +Timezone: Fuseau horaire +Locale: Locale +ProcessExecution: Exécution des processus +Code: Code +Status: Status +Start Date: Date de début +End Date: Date de fin +Source: Source +Target: Destination +Duration: Durée +Report: Rapport +Context: Context +Launch: Exécuter +View executions: Voir les éxécutions +Process code: Code du processus +Last execution: Dernière exécution +Actions: Actions +LogRecord: Logs +Level: Niveau +Message: Message +Created At: Date de création +Has context info ?: Context ? +ProcessSchedule: Planificateur +To run scheduler, ensure "bin/console messenger:consume scheduler_cron" console is alive. See https://symfony.com/doc/current/messenger.html#supervisor-configuration.: "Pour fonctionner, assurez vous que la commande \"bin/console messenger:consume scheduler_cron\" soit exécutée. Plus d''informations sur https://symfony.com/doc/current/messenger.html#supervisor-configuration." +Delete: Supprimer +Type: Type +Expression: Expression +Next Execution: Prochaîne exécution +Input: Input +General: Général +Context (key/value): Context (clé/valeur) +key: clé +value: valeur +Context Key: Context clé +Context Value: Context valeur