diff --git a/Block/Adminhtml/Dashboard/Cronjobs.php b/Block/Adminhtml/Dashboard/Cronjobs.php new file mode 100644 index 0000000..e7a2ae4 --- /dev/null +++ b/Block/Adminhtml/Dashboard/Cronjobs.php @@ -0,0 +1,83 @@ +scheduleCollectionFactory = $scheduleCollectionFactory; + parent::__construct($context); + } + + /** + * Get Top running jobs + * + * @return \KiwiCommerce\CronScheduler\Model\ResourceModel\Schedule\Collection + */ + public function getTopRunningJobs() + { + $collection = $this->scheduleCollectionFactory->create(); + + $collection->addFieldToFilter('status', \Magento\Cron\Model\Schedule::STATUS_SUCCESS) + ->addExpressionFieldToSelect( + 'timediff', + 'TIME_TO_SEC(TIMEDIFF(`finished_at`, `executed_at`))', + [] + ) + ->setOrder('TIME_TO_SEC(TIMEDIFF(`finished_at`, `executed_at`))', 'DESC') + ->setPageSize(self::TOTAL_RECORDS_ON_DASHBOARD) + ->load(); + + return $collection; + } + + /** + * Check store configuration value for dashboard + * @return mixed + */ + public function isDashboardActive() + { + $dashboardEnableStatus = $this->_scopeConfig->getValue(self::XML_PATH_DASHBOARD_ENABLE_STATUS, \Magento\Store\Model\ScopeInterface::SCOPE_STORE); + + return $dashboardEnableStatus; + } +} diff --git a/Block/Adminhtml/DefaultBlocks/Setvalues.php b/Block/Adminhtml/DefaultBlocks/Setvalues.php new file mode 100644 index 0000000..d9bdec4 --- /dev/null +++ b/Block/Adminhtml/DefaultBlocks/Setvalues.php @@ -0,0 +1,53 @@ +configReader = $configReader; + parent::__construct($context); + } + + /** + * Get Admin URL + * @return string + * @throws \Exception + * @throws \Magento\Framework\Exception\FileSystemException + */ + public function getAdminBaseUrl() + { + $config = $this->configReader->load(); + $adminSuffix = $config['backend']['frontName']; + return $this->getBaseUrl() . $adminSuffix . '/'; + } +} diff --git a/Block/Adminhtml/Form/BackButton.php b/Block/Adminhtml/Form/BackButton.php new file mode 100644 index 0000000..8c9b5f6 --- /dev/null +++ b/Block/Adminhtml/Form/BackButton.php @@ -0,0 +1,47 @@ + __('Back'), + 'on_click' => sprintf("location.href = '%s';", $this->getUrl('cronscheduler/job/listing')), + 'class' => 'back', + 'sort_order' => 10 + ]; + } + + /** + * Get URL for back (reset) button + * + * @return string + */ + public function getBackUrl() + { + return $this->getUrl('*/*/'); + } +} diff --git a/Block/Adminhtml/Form/GenericButton.php b/Block/Adminhtml/Form/GenericButton.php new file mode 100644 index 0000000..b18a052 --- /dev/null +++ b/Block/Adminhtml/Form/GenericButton.php @@ -0,0 +1,73 @@ +urlBuilder = $context->getUrlBuilder(); + $this->registry = $registry; + } + + /** + * Return the synonyms group Id. + * + * @return int|null + */ + public function getId() + { + $contact = $this->registry->registry('contact'); + return $contact ? $contact->getId() : null; + } + + /** + * Generate url by route and parameters + * + * @param string $route + * @param array $params + * @return string + */ + public function getUrl($route = '', $params = []) + { + return $this->urlBuilder->getUrl($route, $params); + } +} diff --git a/Block/Adminhtml/Form/SaveButton.php b/Block/Adminhtml/Form/SaveButton.php new file mode 100644 index 0000000..a569d69 --- /dev/null +++ b/Block/Adminhtml/Form/SaveButton.php @@ -0,0 +1,40 @@ + __('Save'), + 'class' => 'save primary', + 'data_attribute' => [ + 'mage-init' => ['button' => ['event' => 'save']], + 'form-role' => 'save', + ], + 'sort_order' => 90, + ]; + } +} diff --git a/Block/Adminhtml/Schedule/Timeline.php b/Block/Adminhtml/Schedule/Timeline.php new file mode 100644 index 0000000..6316e25 --- /dev/null +++ b/Block/Adminhtml/Schedule/Timeline.php @@ -0,0 +1,228 @@ +datetime = $datetime; + $this->scheduleHelper = $scheduleHelper; + $this->collectionFactory = $collectionFactory; + $this->productMetaData = $productMetaData; + parent::__construct($context, $data); + } + + /** + * Get the data to construct the timeline + * @return array + */ + public function getCronJobData() + { + $data = []; + $schedules = $this->collectionFactory->create(); + $schedules->getSelect()->order('job_code'); + + foreach ($schedules as $schedule) { + $start = $schedule->getExecutedAt(); + $end = $schedule->getFinishedAt(); + $status = $schedule->getStatus(); + + if ($start == null) { + $start = $end = $schedule->getScheduledAt(); + } + + if ($status == \Magento\Cron\Model\Schedule::STATUS_RUNNING) { + $end = $this->datetime->date('Y-m-d H:i:s'); + } + + if ($status == \Magento\Cron\Model\Schedule::STATUS_ERROR && $end == null) { + $end = $start; + } + + $level = $this->getStatusLevel($status); + $tooltip = $this->getToolTip($schedule, $level, $status, $start, $end); + + $data[] = [ + $schedule->getJobCode(), + $status, + $tooltip, + $this->getNewDateForJs($start), + $this->getNewDateForJs($end), + $schedule->getScheduleId() + ]; + } + + return $data; + } + + /** + * Generate js date format for given date + * @param $date + * @return string + */ + private function getNewDateForJs($date) + { + return "new Date(" . $this->datetime->date('Y,', $date) . ($this->datetime->date('m', $date) - 1) . $this->datetime->date(',d,H,i,s,0', $date) . ")"; + } + + /** + * Get Status Level + * @param $status + * @return string + */ + private function getStatusLevel($status) + { + switch ($status) { + case \Magento\Cron\Model\Schedule::STATUS_ERROR: + case \Magento\Cron\Model\Schedule::STATUS_MISSED: + $level = 'major'; + break; + case \Magento\Cron\Model\Schedule::STATUS_RUNNING: + $level = 'running'; + break; + case \Magento\Cron\Model\Schedule::STATUS_PENDING: + $level = 'minor'; + break; + case \Magento\Cron\Model\Schedule::STATUS_SUCCESS: + $level = 'notice'; + break; + default: + $level = 'critical'; + } + + return $level; + } + + /** + * Get tooltip text for each cron job + * @param $schedule + * @param $level + * @param $status + * @param $start + * @param $end + * @return string + */ + private function getToolTip($schedule, $level, $status, $start, $end) + { + $tooltip = "" + . "" + . "" + . "" + . "" + . "" + . "" + . ""; + + if ($status== "success") { + $timeFirst = strtotime($start); + $timeSecond = strtotime($end); + $differenceInSeconds = $timeSecond - $timeFirst; + + $tooltip .= "" + . "" + . "" + . ""; + } + $tooltip .= "
" + . $schedule->getJobCode() + . "
" + . "Id" + . "" + . $schedule->getId() . "
" + . "Status" + . "" + . "" . $status . "" + . "
" + . "Created at" + . "" + . $schedule->getCreatedAt() + . "
" + . "Scheduled at" + . "" + . $schedule->getScheduledAt() + . "
" + . "Executed at" + . "" + . ($start != null ? $start : "") + . "
" + . "Finished at" + . "" + . ($end != null ? $end : "") + . "
" + . "CPU Usage" + . "" + . $schedule->getCpuUsage() + . "
" + . "System Usage" + . "" + . $schedule->getSystemUsage() + . "
" + . "Memory Usage" + . "" + . $schedule->getMemoryUsage() + . "
" + . "Total Executed Time" + . "" + . $differenceInSeconds + . "
"; + + return $tooltip; + } + + /** + * Get the current date for javascript + * @return string + */ + public function getDateWithJs() + { + $current = $this->datetime->date('U') + $this->datetime->getGmtOffSet('seconds'); + return "new Date(" . $this->datetime->date("Y,", $current) . ($this->datetime->date("m", $current) - 1) . $this->datetime->date(",d,H,i,s", $current) . ")"; + } +} diff --git a/Controller/Adminhtml/Cron/LongJobChecker.php b/Controller/Adminhtml/Cron/LongJobChecker.php new file mode 100644 index 0000000..280d391 --- /dev/null +++ b/Controller/Adminhtml/Cron/LongJobChecker.php @@ -0,0 +1,96 @@ +dateTime = $dateTime; + $this->scheduleCollectionFactory = $scheduleCollectionFactory; + parent::__construct($context); + } + + /** + * Execute action + */ + public function execute() + { + $collection = $this->scheduleCollectionFactory->create(); + $time = strftime('%Y-%m-%d %H:%M:%S', $this->dateTime->gmtTimestamp($this->timePeriod)); + + $jobs = $collection->addFieldToFilter('status', \Magento\Cron\Model\Schedule::STATUS_RUNNING) + ->addFieldToFilter( + 'finished_at', + ['null' => true] + ) + ->addFieldToFilter( + 'executed_at', + ['lt' => $time] + ) + ->addFieldToSelect(['schedule_id','pid']) + ->load(); + + foreach ($jobs as $job) { + $pid = $job->getPid(); + + $finished_at = strftime('%Y-%m-%d %H:%M:%S', $this->dateTime->gmtTimestamp()); + if (function_exists('posix_getsid') && posix_getsid($pid) === false) { + $job->setData('status', \Magento\Cron\Model\Schedule::STATUS_ERROR); + $job->setData('messages', __('Execution stopped due to some error.')); + $job->setData('finished_at', $finished_at); + } else { + posix_kill($pid, 9); + $job->setData('status', self::STATUS_KILLED); + $job->setData('messages', __('It is killed as running for longer period.')); + $job->setData('finished_at', $finished_at); + } + $job->save(); + } + } +} diff --git a/Controller/Adminhtml/Cron/Sendemail.php b/Controller/Adminhtml/Cron/Sendemail.php new file mode 100644 index 0000000..b452e2c --- /dev/null +++ b/Controller/Adminhtml/Cron/Sendemail.php @@ -0,0 +1,236 @@ +scheduleCollectionFactory = $scheduleCollectionFactory; + $this->transportBuilder = $transportBuilder; + $this->inlineTranslation = $inlineTranslation; + $this->scopeConfig = $scopeConfig; + $this->dateTime = $dateTime; + $this->senderResolver = $senderResolver; + $this->storeManager = $storeManager; + $this->logger = $logger; + } + + /** + * Execute action + * @return \Magento\Framework\App\ResponseInterface|\Magento\Framework\Controller\ResultInterface|string + */ + public function execute() + { + $emailEnableStatus = $this->scopeConfig->getValue(self::XML_PATH_EMAIL_ENABLE_STATUS, \Magento\Store\Model\ScopeInterface::SCOPE_STORE); + + if ($emailEnableStatus) { + $emailItems['errorMessages'] = $this->getFatalErrorOfJobcode(); + $emailItems['missedJobs'] = $this->getMissedCronJob(); + + $receiverEmailConfig = $this->scopeConfig->getValue(self::XML_PATH_EMAIL_RECIPIENT, \Magento\Store\Model\ScopeInterface::SCOPE_STORE); + $receiverEmailIds = explode(',', $receiverEmailConfig); + + if (!empty($receiverEmailIds) && (!empty($emailItems['errorMessages']->getData()) || !empty($emailItems['missedJobs']->getData()))) { + try { + $from = $this->senderResolver->resolve('general'); + + $this->sendEmailStatus($receiverEmailIds, $from, $emailItems); + $this->updateMailStatus($emailItems); + } catch (\Exception $e) { + $this->logger->critical($e); + } + } + } + } + + /** + * Update is mail status after sending an email + * + * @param $emailItems + */ + private function updateMailStatus($emailItems) + { + if (!empty($emailItems['errorMessages'])) { + foreach ($emailItems['errorMessages'] as $errorMessage) { + $collection = $this->scheduleCollectionFactory->create(); + $filters = [ + 'schedule_id' => $errorMessage['max_id'], + 'job_code' => $errorMessage['job_code'], + 'status' => \Magento\Cron\Model\Schedule::STATUS_ERROR + ]; + $collection->updateMailStatusByJobCode(['is_mail_sent' => self::IS_MAIL_STATUS], $filters); + } + } + + if (!empty($emailItems['missedJobs'])) { + foreach ($emailItems['missedJobs'] as $missedJob) { + $collection = $this->scheduleCollectionFactory->create(); + $filters = [ + 'schedule_id' => $missedJob['max_id'], + 'job_code' => $missedJob['job_code'], + 'status' => \Magento\Cron\Model\Schedule::STATUS_MISSED + ]; + $collection->updateMailStatusByJobCode(['is_mail_sent' => self::IS_MAIL_STATUS], $filters); + } + } + } + + /** + * Get Missed cron jobs count + * + * @return \KiwiCommerce\CronScheduler\Model\ResourceModel\Schedule\Collection + */ + private function getMissedCronJob() + { + $collection = $this->scheduleCollectionFactory->create(); + $collection->getSelect()->where('status = "'.\Magento\Cron\Model\Schedule::STATUS_MISSED.'"') + ->where('is_mail_sent is NULL') + ->reset('columns') + ->columns(['job_code', 'MAX(schedule_id) as max_id', 'COUNT(schedule_id) as totalmissed']) + ->group(['job_code']); + + return $collection; + } + + /** + * Get Each Cron Job Fatal error + * + * @return \KiwiCommerce\CronScheduler\Model\ResourceModel\Schedule\Collection + */ + private function getFatalErrorOfJobcode() + { + $collection = $this->scheduleCollectionFactory->create(); + $collection->getSelect()->where('status = "'.\Magento\Cron\Model\Schedule::STATUS_ERROR.'"') + ->where('error_message is not NULL') + ->where('is_mail_sent is NULL') + ->reset('columns') + ->columns(['job_code', 'error_message','MAX(schedule_id) as max_id']) + ->group(['job_code']); + + return $collection; + } + + /** + * Send Email + * @param $to + * @param $from + * @param $items + * @return $this + * @throws \Magento\Framework\Exception\MailException + */ + private function sendEmailStatus($to, $from, $items) + { + $templateOptions = ['area' => \Magento\Framework\App\Area::AREA_FRONTEND, 'store' => $this->storeManager->getStore()->getId()]; + $templateVars = [ + 'store' => $this->storeManager->getStore(), + 'items'=> $items, + ]; + + $this->inlineTranslation->suspend(); + + $transport = $this->transportBuilder->setTemplateIdentifier(self::TEST_EMAIL_TEMPLATE) + ->setTemplateOptions($templateOptions) + ->setTemplateVars($templateVars) + ->setFrom($from) + ->addTo($to) + ->getTransport(); + $transport->sendMessage(); + + $this->inlineTranslation->resume(); + return $this; + } +} diff --git a/Controller/Adminhtml/Job/Delete.php b/Controller/Adminhtml/Job/Delete.php new file mode 100644 index 0000000..25a05f2 --- /dev/null +++ b/Controller/Adminhtml/Job/Delete.php @@ -0,0 +1,108 @@ +cacheTypeList = $cacheTypeList; + $this->jobHelper = $jobHelper; + $this->scheduleCollectionFactory = $scheduleCollectionFactory; + $this->jobModel = $jobModel; + parent::__construct($context); + } + + /** + * Is action allowed? + * @return boolean + */ + protected function _isAllowed() + { + return $this->_authorization->isAllowed('KiwiCommerce_CronScheduler::'.$this->aclResource); + } + + /** + * Execute action + * @return \Magento\Framework\App\ResponseInterface|\Magento\Framework\Controller\ResultInterface + */ + public function execute() + { + $jobcode = $this->getRequest()->getParam('job_code'); + $group = $this->getRequest()->getParam('group'); + + if (!empty($jobcode) && !empty($group)) { + if ($this->jobHelper->isXMLJobcode($jobcode, $group)) { + $this->messageManager->addErrorMessage(__('The cron job can not be deleted.')); + } else { + $collection = $this->scheduleCollectionFactory->create(); + $collection->addFieldToFilter('job_code', $jobcode); + + foreach ($collection as $job) { + $job->delete(); + } + + $this->jobModel->deleteJob($group, $jobcode); + + $this->cacheTypeList->cleanType('config'); + $this->messageManager->addSuccessMessage(__('A total of 1 record(s) have been deleted.')); + } + } + + return $this->_redirect('*/*/listing'); + } +} diff --git a/Controller/Adminhtml/Job/Edit.php b/Controller/Adminhtml/Job/Edit.php new file mode 100644 index 0000000..0dbde65 --- /dev/null +++ b/Controller/Adminhtml/Job/Edit.php @@ -0,0 +1,61 @@ +_authorization->isAllowed('KiwiCommerce_CronScheduler::'.$this->aclResource); + } + + /** + * Execute action + * @return \Magento\Framework\App\ResponseInterface|\Magento\Framework\Controller\ResultInterface + */ + public function execute() + { + $resultPage = $this->resultFactory->create(ResultFactory::TYPE_PAGE); + $resultPage->setActiveMenu("Magento_Backend::system"); + $resultPage->getConfig()->getTitle()->prepend(__('Edit Cron Job')); + $resultPage->addBreadcrumb(__('Cron Scheduler'), __('Cron Scheduler')); + return $resultPage; + } +} diff --git a/Controller/Adminhtml/Job/Listing.php b/Controller/Adminhtml/Job/Listing.php new file mode 100644 index 0000000..43b3007 --- /dev/null +++ b/Controller/Adminhtml/Job/Listing.php @@ -0,0 +1,70 @@ +scheduleHelper = $scheduleHelper; + parent::__construct($context); + } + + /** + * Is action allowed? + * @return boolean + */ + protected function _isAllowed() + { + return $this->_authorization->isAllowed('KiwiCommerce_CronScheduler::'.$this->aclResource); + } + + /** + * Execute action + * @return \Magento\Framework\App\ResponseInterface|\Magento\Framework\Controller\ResultInterface + */ + public function execute() + { + $this->scheduleHelper->getLastCronStatusMessage(); + $resultPage = $this->resultFactory->create(ResultFactory::TYPE_PAGE); + $resultPage->setActiveMenu("Magento_Backend::system"); + $resultPage->getConfig()->getTitle()->prepend(__('Cron Jobs')); + $resultPage->addBreadcrumb(__('Cron Scheduler'), __('Cron Scheduler')); + return $resultPage; + } +} diff --git a/Controller/Adminhtml/Job/MassScheduleNow.php b/Controller/Adminhtml/Job/MassScheduleNow.php new file mode 100644 index 0000000..d2083f5 --- /dev/null +++ b/Controller/Adminhtml/Job/MassScheduleNow.php @@ -0,0 +1,143 @@ +scheduleCollectionFactory = $scheduleCollectionFactory; + $this->timezone = $timezone; + $this->dateTime = $dateTime; + $this->scheduleHelper = $scheduleHelper; + $this->jobHelper = $jobHelper; + + parent::__construct($context); + } + + /** + * Is action allowed? + * @return boolean + */ + protected function _isAllowed() + { + return $this->_authorization->isAllowed('KiwiCommerce_CronScheduler::'.$this->aclResource); + } + + /** + * Execute action + * @return \Magento\Framework\App\ResponseInterface|\Magento\Framework\Controller\ResultInterface + */ + public function execute() + { + $data = $this->getRequest()->getPostValue(); + + if (isset($data['selected'])) { + $jobCodes = $data['selected']; + } elseif (!isset($data['selected']) && isset($data['excluded'])) { + $filters = $data['filters']; + unset($filters['placeholder']); + $jobCodes = $this->jobHelper->getAllFilterJobCodes($filters); + } + + if (empty($jobCodes)) { + $this->messageManager->addErrorMessage(__('Selected jobs can not be scheduled now.')); + return $this->_redirect('*/*/listing'); + } + + try { + foreach ($jobCodes as $jobCode) { + $job_status = $this->jobHelper->isJobActive($jobCode); + if ($job_status) { + $collection = $this->scheduleCollectionFactory->create()->getNewEmptyItem(); + + $magentoVersion = $this->scheduleHelper->getMagentoversion(); + if (version_compare($magentoVersion, "2.2.0") >= 0) { + $createdAt = strftime('%Y-%m-%d %H:%M:%S', $this->dateTime->gmtTimestamp()); + $scheduleAt = strftime('%Y-%m-%dT%H:%M:%S', $this->dateTime->gmtTimestamp()); + } else { + $createdAt = strftime('%Y-%m-%d %H:%M:%S', $this->timezone->scopeTimeStamp()); + $scheduleAt = strftime('%Y-%m-%dT%H:%M:%S', $this->timezone->scopeTimeStamp()); + } + + $collection->setData('job_code', $jobCode); + $collection->setData('status', \Magento\Cron\Model\Schedule::STATUS_PENDING); + $collection->setData('created_at', $createdAt); + $collection->setData('scheduled_at', $this->scheduleHelper->filterTimeInput($scheduleAt)); + $collection->save(); + $success[] = $jobCode; + } + } + } catch (\Exception $e) { + $this->messageManager->addErrorMessage($e->getMessage()); + return $this->_redirect('*/*/listing'); + } + + if (isset($success) && !empty($success)) { + $this->messageManager->addSuccessMessage(__('You scheduled selected jobs now.')); + } + + return $this->_redirect('*/*/listing'); + } +} diff --git a/Controller/Adminhtml/Job/MassStatusDisable.php b/Controller/Adminhtml/Job/MassStatusDisable.php new file mode 100644 index 0000000..1a0fc52 --- /dev/null +++ b/Controller/Adminhtml/Job/MassStatusDisable.php @@ -0,0 +1,110 @@ +jobHelper = $jobHelper; + $this->jobModel = $jobModel; + $this->cacheTypeList = $cacheTypeList; + parent::__construct($context); + } + + /** + * Is action allowed? + * @return boolean + */ + protected function _isAllowed() + { + return $this->_authorization->isAllowed('KiwiCommerce_CronScheduler::'.$this->aclResource); + } + + /** + * Execute action + * @return \Magento\Framework\App\ResponseInterface|\Magento\Framework\Controller\ResultInterface + */ + public function execute() + { + $data = $this->getRequest()->getPostValue(); + + if (isset($data['selected'])) { + $jobCodes = $data['selected']; + } elseif (!isset($data['selected']) && isset($data['excluded'])) { + $filters = $data['filters']; + unset($filters['placeholder']); + $jobCodes = $this->jobHelper->getAllFilterJobCodes($filters); + } + + if (empty($jobCodes)) { + $this->messageManager->addErrorMessage(__('Selected jobs can not be disabled.')); + return $this->_redirect('*/*/listing'); + } + + try { + foreach ($jobCodes as $jobCode) { + $data = $this->jobHelper->getJobDetail($jobCode); + $this->jobModel->changeJobStatus($data, self::CRON_JOB_DISABLE_STATUS); + } + $this->cacheTypeList->cleanType('config'); + $this->messageManager->addSuccessMessage(__('You disabled selected jobs.')); + } catch (\Exception $e) { + $this->messageManager->addErrorMessage($e->getMessage()); + return $this->_redirect('*/*/listing'); + } + return $this->_redirect('*/*/listing'); + } +} diff --git a/Controller/Adminhtml/Job/MassStatusEnable.php b/Controller/Adminhtml/Job/MassStatusEnable.php new file mode 100644 index 0000000..5743f33 --- /dev/null +++ b/Controller/Adminhtml/Job/MassStatusEnable.php @@ -0,0 +1,110 @@ +jobHelper = $jobHelper; + $this->jobModel = $jobModel; + $this->cacheTypeList = $cacheTypeList; + parent::__construct($context); + } + + /** + * Is action allowed? + * @return boolean + */ + protected function _isAllowed() + { + return $this->_authorization->isAllowed('KiwiCommerce_CronScheduler::'.$this->aclResource); + } + + /** + * Execute action + * @return \Magento\Framework\App\ResponseInterface|\Magento\Framework\Controller\ResultInterface + */ + public function execute() + { + $data = $this->getRequest()->getPostValue(); + + if (isset($data['selected'])) { + $jobCodes = $data['selected']; + } elseif (!isset($data['selected']) && isset($data['excluded'])) { + $filters = $data['filters']; + unset($filters['placeholder']); + $jobCodes = $this->jobHelper->getAllFilterJobCodes($filters); + } + + if (empty($jobCodes)) { + $this->messageManager->addErrorMessage(__('Selected jobs can not be enabled.')); + return $this->_redirect('*/*/listing'); + } + + try { + foreach ($jobCodes as $jobCode) { + $data = $this->jobHelper->getJobDetail($jobCode); + $this->jobModel->changeJobStatus($data, self::CRON_JOB_ENABLE_STATUS); + } + $this->cacheTypeList->cleanType('config'); + $this->messageManager->addSuccessMessage(__('You enabled selected jobs.')); + } catch (\Exception $e) { + $this->messageManager->addErrorMessage($e->getMessage()); + return $this->_redirect('*/*/listing'); + } + return $this->_redirect('*/*/listing'); + } +} diff --git a/Controller/Adminhtml/Job/NewAction.php b/Controller/Adminhtml/Job/NewAction.php new file mode 100644 index 0000000..6eb6fe6 --- /dev/null +++ b/Controller/Adminhtml/Job/NewAction.php @@ -0,0 +1,61 @@ +_authorization->isAllowed('KiwiCommerce_CronScheduler::'.$this->aclResource); + } + + /** + * Execute action + * @return \Magento\Framework\App\ResponseInterface|\Magento\Framework\Controller\ResultInterface + */ + public function execute() + { + $resultPage = $this->resultFactory->create(ResultFactory::TYPE_PAGE); + $resultPage->setActiveMenu("Magento_Backend::system"); + $resultPage->getConfig()->getTitle()->prepend(__('Add New Cron Job')); + $resultPage->addBreadcrumb(__('Cron Scheduler'), __('Cron Scheduler')); + return $resultPage; + } +} diff --git a/Controller/Adminhtml/Job/Save.php b/Controller/Adminhtml/Job/Save.php new file mode 100644 index 0000000..f7a2e9e --- /dev/null +++ b/Controller/Adminhtml/Job/Save.php @@ -0,0 +1,133 @@ +cacheTypeList = $cacheTypeList; + $this->jobHelper = $jobHelper; + $this->jobModel = $jobModel; + parent::__construct($context); + } + + /** + * Execute action + * @return \Magento\Framework\App\ResponseInterface|\Magento\Framework\Controller\ResultInterface + */ + public function execute() + { + $data = $this->getRequest()->getPostValue(); + + try { + if ($data) { + $data = $this->jobHelper->trimArray($data); + #Cron Expression Array + $cronExprArray = $this->jobHelper->trimArray(explode(',', $data['schedule'])); + $jobData = $this->jobHelper->getJobData(); + $flagMultipleExpression = false; + + #check is multiple expression + if (count($cronExprArray) > 1) { + $flagMultipleExpression = true; + $counter = 1; + } + foreach ($cronExprArray as $cronExprKey => $cronExpr) { + $cronExistResponse = $this->jobHelper->checkIfCronExists($jobData, $cronExpr, $data); + + #skip the row if already exist. + if ($cronExistResponse) { + $error[] = $cronExpr; + if ($data['mode'] == "edit" && $cronExprKey == 0) { + $error = $this->popElement($data, $error, $cronExpr); + } + continue; + } + + #check the mode + if ($flagMultipleExpression && (($data['mode'] == "edit" && $cronExprKey != 0) || ($data['mode'] == "add"))) { + $result = $this->jobHelper->getCronJobName($jobData, $data['code'], $counter); + $jobcode = $result['jobcode']; + $counter = $result['counter']; + } else { + $jobcode = $data['code']; + } + + $this->jobModel->saveJob($data, $cronExpr, $jobcode); + $sucess[] = $cronExpr; + } + + $this->cacheTypeList->cleanType('config'); + if (isset($sucess) && !empty($sucess)) { + $this->messageManager->addSuccessMessage(__('You saved the cron job for expressions - '.join(',', $sucess))); + } + if (isset($error) && !empty($error)) { + $this->messageManager->addWarningMessage(__('The cron already exists for expressions - '.join(',', $error))); + } + } + } catch (\Exception $e) { + $this->messageManager->addErrorMessage($e->getMessage()); + return $this->_redirect('*/*/listing'); + } + + return $this->_redirect('*/*/listing'); + } + + /** + * Pop last element from array + * @param $data + * @param $error + * @return mixed + */ + private function popElement($data, $error, $cronExpr) + { + if (isset($data['oldexpressionvalue']) && $cronExpr == $data['oldexpressionvalue']) { + array_pop($error); + } + + return $error; + } +} diff --git a/Controller/Adminhtml/Schedule/Listing.php b/Controller/Adminhtml/Schedule/Listing.php new file mode 100644 index 0000000..5ae2182 --- /dev/null +++ b/Controller/Adminhtml/Schedule/Listing.php @@ -0,0 +1,70 @@ +scheduleHelper = $scheduleHelper; + parent::__construct($context); + } + + /** + * Is action allowed? + * @return boolean + */ + protected function _isAllowed() + { + return $this->_authorization->isAllowed('KiwiCommerce_CronScheduler::'.$this->aclResource); + } + + /** + * Action to display the tasks listing + * @return \Magento\Framework\View\Result\Page + */ + public function execute() + { + $this->scheduleHelper->getLastCronStatusMessage(); + $resultPage = $this->resultFactory->create(ResultFactory::TYPE_PAGE); + $resultPage->setActiveMenu("Magento_Backend::system"); + $resultPage->getConfig()->getTitle()->prepend(__('Cron Job Schedule List')); + $resultPage->addBreadcrumb(__('Cron Scheduler'), __('Cron Scheduler')); + return $resultPage; + } +} diff --git a/Controller/Adminhtml/Schedule/MassDelete.php b/Controller/Adminhtml/Schedule/MassDelete.php new file mode 100644 index 0000000..ec49a28 --- /dev/null +++ b/Controller/Adminhtml/Schedule/MassDelete.php @@ -0,0 +1,84 @@ +scheduleCollectionFactory = $scheduleCollectionFactory; + $this->filter = $filter; + parent::__construct($context); + } + + /** + * Is action allowed? + * @return boolean + */ + protected function _isAllowed() + { + return $this->_authorization->isAllowed('KiwiCommerce_CronScheduler::'.$this->aclResource); + } + + /** + * Execute action + */ + public function execute() + { + try { + $collection = $this->filter->getCollection($this->scheduleCollectionFactory->create()); + $collectionSize = $collection->getSize(); + + foreach ($collection as $job) { + $job->delete(); + } + + $this->messageManager->addSuccessMessage(__('A total of %1 record(s) have been deleted.', $collectionSize)); + return $this->_redirect('*/*/listing'); + } catch (\Exception $e) { + + $this->messageManager->addErrorMessage($e->getMessage()); + return $this->_redirect('*/*/listing'); + } + } +} diff --git a/Controller/Adminhtml/Schedule/MassKill.php b/Controller/Adminhtml/Schedule/MassKill.php new file mode 100644 index 0000000..865029b --- /dev/null +++ b/Controller/Adminhtml/Schedule/MassKill.php @@ -0,0 +1,145 @@ +scheduleCollectionFactory = $scheduleCollectionFactory; + $this->dateTime = $dateTime; + $this->filter = $filter; + parent::__construct($context); + } + + /** + * Is action allowed? + * @return boolean + */ + protected function _isAllowed() + { + return $this->_authorization->isAllowed('KiwiCommerce_CronScheduler::'.$this->aclResource); + } + + /** + * Execute action + * @return \Magento\Framework\App\ResponseInterface|\Magento\Framework\Controller\ResultInterface + * @throws \Magento\Framework\Exception\LocalizedException + */ + public function execute() + { + $data = $this->getRequest()->getPostValue(); + + if (!isset($data['selected']) && isset($data['excluded'])) { + $collection = $this->filter->getCollection($this->scheduleCollectionFactory->create()); + $ids = $collection->getAllIds(); + } else { + $collection = $this->scheduleCollectionFactory->create(); + $ids = $data['selected']; + } + + if (empty($ids)) { + $this->messageManager->addErrorMessage(__('Selected jobs can not be killed.')); + return $this->_redirect('*/*/listing'); + } + + try { + $collection->addFieldToFilter('status', \Magento\Cron\Model\Schedule::STATUS_RUNNING) + ->addFieldToFilter( + 'finished_at', + ['null' => true] + ) + ->addFieldToFilter( + 'schedule_id', + ['in' => $ids] + ) + ->addFieldToSelect(['schedule_id','pid']) + ->load(); + + $killedJobData = $collection->getData(); + $killedScheduleIds = $errorScheduleIds = []; + $runningJobs = array_column($killedJobData, 'schedule_id'); + $errorScheduleIds = array_diff($ids, $runningJobs); + + foreach ($collection as $dbrunningjobs) { + $pid = $dbrunningjobs->getPid(); + $scheduleId = $dbrunningjobs->getScheduleId(); + + if (function_exists('posix_getsid') && posix_getsid($pid) === false) { + $errorScheduleIds[] = $scheduleId; + } else { + $finished_at = strftime('%Y-%m-%d %H:%M:%S', $this->dateTime->gmtTimestamp()); + $dbrunningjobs->setData('status', self::STATUS_KILLED); + $dbrunningjobs->setData('messages', __('It is killed by admin.')); + $dbrunningjobs->setData('finished_at', $finished_at); + $dbrunningjobs->save(); + + posix_kill($pid, 9); + $killedScheduleIds[] = $scheduleId; + } + } + if (!empty($killedScheduleIds)) { + $this->messageManager->addSuccessMessage(__('A total of %1 record(s) have been killed - ' . join(',', $killedScheduleIds),count($killedScheduleIds))); + } + if (!empty($errorScheduleIds)) { + $this->messageManager->addErrorMessage(__('A total of %1 record(s) can not be killed - ' . join(',', $errorScheduleIds),count($errorScheduleIds))); + } + + return $this->_redirect('*/*/listing'); + } catch (\Exception $e) { + $this->messageManager->addErrorMessage($e->getMessage()); + return $this->_redirect('*/*/listing'); + } + } +} diff --git a/Controller/Adminhtml/Schedule/Timeline.php b/Controller/Adminhtml/Schedule/Timeline.php new file mode 100644 index 0000000..51614d1 --- /dev/null +++ b/Controller/Adminhtml/Schedule/Timeline.php @@ -0,0 +1,70 @@ +scheduleHelper = $scheduleHelper; + parent::__construct($context); + } + + /** + * Is action allowed? + * @return boolean + */ + protected function _isAllowed() + { + return $this->_authorization->isAllowed('KiwiCommerce_CronScheduler::'.$this->aclResource); + } + + /** + * Action to display timeline + * @return \Magento\Framework\View\Result\Page + */ + public function execute() + { + $this->scheduleHelper->getLastCronStatusMessage(); + $resultPage = $this->resultFactory->create(ResultFactory::TYPE_PAGE); + $resultPage->setActiveMenu("Magento_Backend::system"); + $resultPage->getConfig()->getTitle()->prepend(__('Cron Scheduler Timeline')); + $resultPage->addBreadcrumb(__('CronScheduler'), __('CronScheduler')); + return $resultPage; + } +} diff --git a/Controller/Adminhtml/Validation/ClassExistance.php b/Controller/Adminhtml/Validation/ClassExistance.php new file mode 100644 index 0000000..94bf837 --- /dev/null +++ b/Controller/Adminhtml/Validation/ClassExistance.php @@ -0,0 +1,58 @@ +getRequest()->isXmlHttpRequest()) { + $data = $this->getRequest()->getPostValue(); + $classpath = trim($data['classpath']); + $resultJson = $this->resultFactory->create(ResultFactory::TYPE_JSON); + $result = false; + + if (!empty($classpath)) { + if (class_exists($classpath)) { + $result = true; + } + } + + return $resultJson->setData(['success' => $result]); + } + + return $this->_redirect('*/*/listing'); + } +} diff --git a/Controller/Adminhtml/Validation/CronExpression.php b/Controller/Adminhtml/Validation/CronExpression.php new file mode 100644 index 0000000..94c47d7 --- /dev/null +++ b/Controller/Adminhtml/Validation/CronExpression.php @@ -0,0 +1,67 @@ +getRequest()->isXmlHttpRequest()) { + $data = $this->getRequest()->getPostValue(); + $exprArray = explode(',', $data['expression']); + $resultJson = $this->resultFactory->create(ResultFactory::TYPE_JSON); + $result = true; + foreach ($exprArray as $expr) { + if (!empty($expr)) { + $e = preg_split('#\s+#', $expr, null, PREG_SPLIT_NO_EMPTY); + if (count($e) < 5 || count($e) > 6) { + $result = false; + break; + } + + if (!preg_match('/^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*\/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])) (\*|([0-9]|1[0-9]|2[0-3])|\*\/([0-9]|1[0-9]|2[0-3])) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|\*\/([1-9]|1[0-9]|2[0-9]|3[0-1])) (\*|([1-9]|1[0-2])|\*\/([1-9]|1[0-2])) (\*|([0-6])|\*\/([0-6]))$/ +', $expr)) { + $result = false; + break; + } + } + } + + return $resultJson->setData(['success' => $result]); + } + + return $this->_redirect('*/*/listing'); + } +} diff --git a/Controller/Adminhtml/Validation/MethodExistance.php b/Controller/Adminhtml/Validation/MethodExistance.php new file mode 100644 index 0000000..6da4c49 --- /dev/null +++ b/Controller/Adminhtml/Validation/MethodExistance.php @@ -0,0 +1,59 @@ +getRequest()->isXmlHttpRequest()) { + $data = $this->getRequest()->getPostValue(); + $classpath = trim($data['classpath']); + $methodname = trim($data['methodname']); + $resultJson = $this->resultFactory->create(ResultFactory::TYPE_JSON); + $result = false; + + if (!empty($classpath)) { + if (method_exists($classpath, $methodname)) { + $result = true; + } + } + + return $resultJson->setData(['success' => $result]); + } + + return $this->_redirect('*/*/listing'); + } +} diff --git a/Controller/Adminhtml/Validation/UniqueJobCode.php b/Controller/Adminhtml/Validation/UniqueJobCode.php new file mode 100644 index 0000000..b2ae05a --- /dev/null +++ b/Controller/Adminhtml/Validation/UniqueJobCode.php @@ -0,0 +1,70 @@ +jobHelper = $jobHelper; + parent::__construct($context); + } + + /** + * Execute action + * @return \Magento\Framework\App\ResponseInterface|\Magento\Framework\Controller\ResultInterface + */ + public function execute() + { + if ($this->getRequest()->isXmlHttpRequest()) { + $data = $this->getRequest()->getPostValue(); + $jobcode = trim($data['jobcode']); + + $data = array_values($this->jobHelper->getJobData()); + $existingjobcode = array_column($data, 'code'); + + $resultJson = $this->resultFactory->create(ResultFactory::TYPE_JSON); + $result = false; + + if (!empty($jobcode)) { + if (in_array($jobcode, $existingjobcode)) { + $result = true; + } + } + + return $resultJson->setData(['success' => $result]); + } + + return $this->_redirect('*/*/listing'); + } +} diff --git a/Cron/Status.php b/Cron/Status.php new file mode 100644 index 0000000..a078012 --- /dev/null +++ b/Cron/Status.php @@ -0,0 +1,33 @@ +setMessages(__("Cron is Working")); + $schedule->save(); + } +} diff --git a/Helper/Cronjob.php b/Helper/Cronjob.php new file mode 100644 index 0000000..9d05812 --- /dev/null +++ b/Helper/Cronjob.php @@ -0,0 +1,301 @@ +cronConfig = $cronConfig; + $this->dbReader = $dbReader; + $this->reader = $reader; + parent::__construct($context); + } + + /** + * Get list of cron jobs. + * + * @return array + */ + public function getJobData() + { + $data = []; + $configJobs = $this->cronConfig->getJobs(); + + foreach ($configJobs as $group => $jobs) { + foreach ($jobs as $code => $job) { + $job = $this->setJobData($job); + $job['code'] = $code; + $job['group'] = $group; + $job['jobtype'] = $this->getJobcodeType($code, $group); + $data[$code] = $job; + } + } + + return $data; + } + + /** + * Get cron job detail. + * @param $jobcode + * @return array + */ + public function getJobDetail($jobcode) + { + $data = []; + $configJobs = $this->cronConfig->getJobs(); + + foreach ($configJobs as $group => $jobs) { + foreach ($jobs as $code => $job) { + if ($code == $jobcode) { + $job = $this->setJobData($job); + $job['code'] = $code; + $job['group'] = $group; + $data = $job; + break; + } + } + } + + return $data; + } + + /** + * Set job data for given job + * @param $job + */ + private function setJobData($job) + { + if (!isset($job['config_schedule'])) { + if (isset($job['schedule'])) { + $job['config_schedule'] = $job['schedule']; + } else { + if (isset($job['config_path'])) { + $job['config_schedule'] = $this->scopeConfig->getValue( + $job['config_path'], + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ); + } else { + $job['config_schedule'] = ""; + } + } + } + if (!isset($job['is_active'])) { + $job['is_active'] = 1; + } + + return $job; + } + + /** + * Check is job code active + * @param $jobCode + * @return bool + */ + public function isJobActive($jobCode) + { + $result = false; + $jobDetail = $this->getJobDetail($jobCode); + if (isset($jobDetail['is_active']) && $jobDetail['is_active']==1) { + $result = true; + } + + return $result; + } + + /** + * Filter Job codes as applied filters + * @param $filters + * @return array + */ + public function getAllFilterJobCodes($filters) + { + $data = array_values($this->getJobData()); + $result = []; + #filters + foreach ($filters as $column => $value) { + $data = array_filter($data, function ($item) use ($column, $value) { + return stripos($item[$column], $value) !== false; + }); + } + + if (!empty($data)) { + $result = array_column($data, 'code'); + } + + return $result; + } + + /** + * Create unique job code name for multiple expression + * @param $jobData + * @param $jobCode + * @param $counter + * @return array + */ + public function getCronJobName($jobData, $jobCode, $counter) + { + $data = array_values($jobData); + $result = []; + $existingJobCode = array_column($data, 'code'); + $appendJobCode = $this->cronAppendString; + + for ($i = $counter; $i <= $counter+100; $i++) { + $cronExprString = strtr($appendJobCode, ['{$counter}' => $i]); + $jobCodeCheck = $jobCode.$cronExprString; + + #check if the same name already in the array or not. + if (!in_array($jobCodeCheck, $existingJobCode)) { + $result['jobcode'] = $jobCodeCheck; + $result['counter'] = $i+1; + $result['status'] = "success"; + break; + } + } + + return $result; + } + + /** + * Check cron exists with same instance method and expression + * @param $jobData + * @param $cronExpr + * @param $instance + * @param $method + * @return bool + */ + public function checkIfCronExists($jobData, $cronExpr, $data) + { + $instance = $data['instance']; + $method = $data['method']; + $result = false; + foreach (array_values($jobData) as $job) { + if ($job['instance']==$instance && $job['method']==$method) { + if (isset($job['schedule']) && $job['schedule'] == $cronExpr) { + $result = true; + break; + } + if (isset($job['config_schedule']) && $job['config_schedule'] == $cronExpr) { + $result = true; + break; + } + } + } + return $result; + } + + /** + * trim the given array + * @param $array + * + * @return $array + */ + public function trimArray($array) + { + $result = array_map('trim', $array); + return $result; + } + + /** + * Check is joncode of xml + * @param $jobCode + * @param $group + * @return bool + */ + public function isXMLJobcode($jobCode, $group) + { + $configJobs = $this->reader->read(); + $result = false; + + if (isset($configJobs[$group][$jobCode])) { + $result = true; + } + return $result; + } + + /** + * Get job code type(db, xml, db_xml) + * @param $jobCode + * @param $group + * @return string + */ + private function getJobcodeType($jobCode, $group) + { + $xmlJobs = $this->reader->read(); + $dbJobs = $this->dbReader->get(); + + $xml = (isset($xmlJobs[$group][$jobCode])) ? true : false; + $db = (isset($dbJobs[$group][$jobCode])) ? true : false; + if ($xml && $db) { + $result = self::CRON_DB_XML; + } elseif (!$xml && $db) { + $result = self::CRON_DB; + } elseif ($xml && !$db) { + $result = self::CRON_XML; + } + + return $result; + } +} diff --git a/Helper/Schedule.php b/Helper/Schedule.php new file mode 100644 index 0000000..e97c7ea --- /dev/null +++ b/Helper/Schedule.php @@ -0,0 +1,172 @@ +scheduleCollectionFactory = $scheduleCollectionFactory; + $this->messageManager = $messageManager; + $this->productMetaData = $productMetaData; + $this->datetime = $datetime; + + parent::__construct($context); + } + + /** + * Store pid in cron table + * + * @param $schedule + */ + public function setPid(&$schedule) + { + if (function_exists('getmypid')) { + $schedule->setPid(getmypid()); + } + } + + /** + * Calculate actual CPU usage in time ms + * @param $ru + * @param $rus + * @param $schedule + */ + public function setCpuUsage($ru, $rus, &$schedule) + { + $cpuData = $this->rutime($ru, $rus, 'utime'); + $systemData = $this->rutime($ru, $rus, 'stime'); + $schedule->setCpuUsage($cpuData); + $schedule->setSystemUsage($systemData); + } + + /** + * Get Usage + * + * @param $ru + * @param $rus + * @param $index + * @return float|int + */ + private function rutime($ru, $rus, $index) + { + return ($ru["ru_$index.tv_sec"]*1000 + intval($ru["ru_$index.tv_usec"]/1000)) + - ($rus["ru_$index.tv_sec"]*1000 + intval($rus["ru_$index.tv_usec"]/1000)); + } + + /** + * Save Memory usage.Convert bytes to megabytes. + * @param $schedule + */ + public function setMemoryUsage(&$schedule) + { + $memory = (memory_get_peak_usage(false)/1024/1024); + + $schedule->setMemoryUsage($memory); + } + + /** + * Generates filtered time input from user to formatted time (YYYY-MM-DD) + * + * @param mixed $time + * @return string + */ + public function filterTimeInput($time) + { + $matches = []; + preg_match('/(\d+-\d+-\d+)T(\d+:\d+)/', $time, $matches); + $time = $matches[1] . " " . $matches[2]; + return strftime('%Y-%m-%d %H:%M:00', strtotime($time)); + } + + /** + * Set last cron status message. + * + */ + public function getLastCronStatusMessage() + { + $magentoVersion = $this->getMagentoversion(); + if (version_compare($magentoVersion, "2.2.0") >= 0) { + $currentTime = $this->datetime->date('U'); + } else { + $currentTime = (int)$this->datetime->date('U') + $this->datetime->getGmtOffset('hours') * 60 * 60; + } + $lastCronStatus = strtotime($this->scheduleCollectionFactory->create()->getLastCronStatus()); + if ($lastCronStatus != null) { + $diff = floor(($currentTime - $lastCronStatus) / 60); + if ($diff > 5) { + if ($diff >= 60) { + $diff = floor($diff / 60); + $this->messageManager->addErrorMessage(__("Last cron execution is older than %1 hour%2", $diff, ($diff > 1) ? "s" : "")); + } else { + $this->messageManager->addErrorMessage(__("Last cron execution is older than %1 minute%2", $diff, ($diff > 1) ? "s" : "")); + } + } else { + $this->messageManager->addSuccessMessage(__("Last cron execution was %1 minute%2 ago", $diff, ($diff > 1) ? "s" : "")); + } + } else { + $this->messageManager->addErrorMessage(__("No cron execution found")); + } + } + + /** + * Get Latest magento Version + * @return mixed + */ + public function getMagentoversion() + { + $explodedVersion = explode("-", $this->productMetaData->getVersion()); + $magentoversion = $explodedVersion[0]; + + return $magentoversion; + } +} diff --git a/Model/Job.php b/Model/Job.php new file mode 100644 index 0000000..c73e2b8 --- /dev/null +++ b/Model/Job.php @@ -0,0 +1,130 @@ +configInterface = $configInterface; + } + + /** + * Save cron job for given data and expression + * @param $data + * @param $cronExpr + * @param $jobCode + */ + public function saveJob($data, $cronExpr, $jobCode) + { + #Cron Expression + $vars = [ + '{$group}' => $data['group'], + '{$jobcode}' => $jobCode + ]; + + $cronExprString = strtr($this->cronExprTemplate, $vars); + $cronModelString = strtr($this->cronModelTemplate, $vars); + $cronStatusString = strtr($this->cronStatusTemplate, $vars); + $cronModelValue = $data['instance'] . "::" . $data['method']; + $cronStatusValue = $data['is_active']; + + $this->configInterface + ->saveConfig($cronExprString, $cronExpr, $this->scope, 0); + $this->configInterface + ->saveConfig($cronModelString, $cronModelValue, $this->scope, 0); + $this->configInterface + ->saveConfig($cronStatusString, $cronStatusValue, $this->scope, 0); + } + + /** + * Delete the job + * + * @param $group + * @param $jobCode + */ + public function deleteJob($group, $jobCode) + { + $vars = [ + '{$group}' => $group, + '{$jobcode}' => $jobCode + ]; + $cronExprString = strtr($this->cronExprTemplate, $vars); + $cronModelString = strtr($this->cronModelTemplate, $vars); + $cronStatusString = strtr($this->cronStatusTemplate, $vars); + + $this->configInterface + ->deleteConfig($cronExprString, $this->scope, 0); + $this->configInterface + ->deleteConfig($cronModelString, $this->scope, 0); + $this->configInterface + ->deleteConfig($cronStatusString, $this->scope, 0); + } + + /** + * Change job Status + * + * @param $jobData + * @param $status + */ + public function changeJobStatus($jobData, $status) + { + $vars = [ + '{$group}' => $jobData['group'], + '{$jobcode}' => $jobData['code'] + ]; + $cronStatusString = strtr($this->cronStatusTemplate, $vars); + $cronStatusValue = $status; + + $this->configInterface + ->saveConfig($cronStatusString, $cronStatusValue, $this->scope, 0); + } +} diff --git a/Model/ResourceModel/Schedule/Collection.php b/Model/ResourceModel/Schedule/Collection.php new file mode 100644 index 0000000..ca0bf49 --- /dev/null +++ b/Model/ResourceModel/Schedule/Collection.php @@ -0,0 +1,68 @@ +getConnection(); + + $connection->update( + $this->getMainTable(), + $data, + [ + 'schedule_id <= ? ' => (int)$filter['schedule_id'], + 'job_code = ?' => $filter['job_code'], + 'status = ?' => $filter['status'], + 'error_message IS NOT NULL', + 'is_mail_sent IS NULL' + ] + ); + } + + /** + * Get the last Cron Status + * @return string | null + */ + public function getLastCronStatus() + { + $this->getSelect()->reset('columns') + ->columns(['executed_at']) + ->where('executed_at is not null and job_code ="kiwicommerce_cronscheduler_status"') + ->order('finished_at desc'); + + $last = $this->getFirstItem(); + if ($last) { + return $last->getExecutedAt(); + } else { + return null; + } + } +} diff --git a/Observer/ProcessCronQueueObserver.php b/Observer/ProcessCronQueueObserver.php new file mode 100644 index 0000000..fbd07a0 --- /dev/null +++ b/Observer/ProcessCronQueueObserver.php @@ -0,0 +1,213 @@ +logger = $logger; + $this->state = $state; + $this->scheduleHelper = $scheduleHelper; + $this->jobHelper = $jobHelper; + } + + /** + * Process cron queue + * Generate tasks schedule + * Cleanup tasks schedule + * + * @param \Magento\Framework\Event\Observer $observer + * @return void + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function execute(\Magento\Framework\Event\Observer $observer) + { + #Handle Fatal Error + $runningSchedule = null; + register_shutdown_function(function () use (&$runningSchedule) { + $errorMessage = error_get_last(); + if ($errorMessage) { + if ($runningSchedule != null) { + $s = $runningSchedule; + $s->setStatus(\Magento\Cron\Model\Schedule::STATUS_ERROR); + $s->setErrorMessage($errorMessage['message']); + $s->setErrorFile($errorMessage['file']); + $s->setErrorLine($errorMessage['line']); + $s->save(); + } + } + }); + + $pendingJobs = $this->_getPendingSchedules(); + $currentTime = $this->dateTime->gmtTimestamp(); + $jobGroupsRoot = $this->_config->getJobs(); + + $phpPath = $this->phpExecutableFinder->find() ?: 'php'; + + foreach ($jobGroupsRoot as $groupId => $jobsRoot) { + $this->_cleanup($groupId); + $this->_generate($groupId); + if ($this->_request->getParam('group') !== null + && $this->_request->getParam('group') !== '\'' . ($groupId) . '\'' + && $this->_request->getParam('group') !== $groupId + ) { + continue; + } + if (($this->_request->getParam(self::STANDALONE_PROCESS_STARTED) !== '1') && ( + $this->_scopeConfig->getValue( + 'system/cron/' . $groupId . '/use_separate_process', + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ) == 1 + ) + ) { + $this->_shell->execute( + $phpPath . ' %s cron:run --group=' . $groupId . ' --' . Cli::INPUT_KEY_BOOTSTRAP . '=' + . self::STANDALONE_PROCESS_STARTED . '=1', + [ + BP . '/bin/magento' + ] + ); + continue; + } + + /** @var \Magento\Cron\Model\Schedule $schedule */ + foreach ($pendingJobs as $schedule) { + $runningSchedule = $schedule; + $jobConfig = isset($jobsRoot[$schedule->getJobCode()]) ? $jobsRoot[$schedule->getJobCode()] : null; + if (!$jobConfig) { + continue; + } + + $scheduledTime = strtotime($schedule->getScheduledAt()); + if ($scheduledTime > $currentTime) { + continue; + } + + try { + if ($schedule->tryLockJob()) { + $this->scheduleHelper->setPid($schedule); + $cpu_before = getrusage(); + $this->_runJob($scheduledTime, $currentTime, $jobConfig, $schedule, $groupId); + $cpu_after = getrusage(); + $this->scheduleHelper->setCpuUsage($cpu_after, $cpu_before, $schedule); + $this->scheduleHelper->setMemoryUsage($schedule); + } + } catch (\Exception $e) { + $schedule->setMessages($e->getMessage()); + $schedule->setErrorMessage($e->getMessage()); + $schedule->setErrorLine($e->getLine()); + + if ($schedule->getStatus() === Schedule::STATUS_ERROR) { + $this->logger->critical($e); + } + if ($schedule->getStatus() === Schedule::STATUS_MISSED + && $this->state->getMode() === State::MODE_DEVELOPER + ) { + $this->logger->info( + sprintf( + "%s Schedule Id: %s Job Code: %s", + $schedule->getMessages(), + $schedule->getScheduleId(), + $schedule->getJobCode() + ) + ); + } + } + $schedule->save(); + } + } + } + + /** + * @param string $jobCode + * @param string $cronExpression + * @param int $timeInterval + * @param array $exists + * @return void + */ + protected function saveSchedule($jobCode, $cronExpression, $timeInterval, $exists) + { + $result = $this->jobHelper->isJobActive($jobCode); + + if ($result) { + parent::saveSchedule($jobCode, $cronExpression, $timeInterval, $exists); + } + } +} diff --git a/Observer/ProcessCronQueueObserver_2.1.php b/Observer/ProcessCronQueueObserver_2.1.php new file mode 100644 index 0000000..3212eb3 --- /dev/null +++ b/Observer/ProcessCronQueueObserver_2.1.php @@ -0,0 +1,178 @@ +scheduleHelper = $scheduleHelper; + $this->jobHelper = $jobHelper; + } + + /** + * Process cron queue + * Generate tasks schedule + * Cleanup tasks schedule + * + * @param \Magento\Framework\Event\Observer $observer + * @return void + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function execute(\Magento\Framework\Event\Observer $observer) + { + #Handle Fatal Error + $runningSchedule = null; + register_shutdown_function(function () use (&$runningSchedule) { + $errorMessage = error_get_last(); + if ($errorMessage) { + if ($runningSchedule != null) { + $s = $runningSchedule; + $s->setStatus(\Magento\Cron\Model\Schedule::STATUS_ERROR); + $s->setErrorMessage($errorMessage['message']); + $s->setErrorFile($errorMessage['file']); + $s->setErrorLine($errorMessage['line']); + $s->save(); + } + } + }); + + $pendingJobs = $this->_getPendingSchedules(); + $currentTime = $this->timezone->scopeTimeStamp(); + $jobGroupsRoot = $this->_config->getJobs(); + + $phpPath = $this->phpExecutableFinder->find() ?: 'php'; + + foreach ($jobGroupsRoot as $groupId => $jobsRoot) { + if ($this->_request->getParam('group') !== null + && $this->_request->getParam('group') !== '\'' . ($groupId) . '\'' + && $this->_request->getParam('group') !== $groupId) { + continue; + } + if (($this->_request->getParam(self::STANDALONE_PROCESS_STARTED) !== '1') && ( + $this->_scopeConfig->getValue( + 'system/cron/' . $groupId . '/use_separate_process', + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ) == 1 + )) { + $this->_shell->execute( + $phpPath . ' %s cron:run --group=' . $groupId . ' --' . CLI::INPUT_KEY_BOOTSTRAP . '=' + . self::STANDALONE_PROCESS_STARTED . '=1', + [ + BP . '/bin/magento' + ] + ); + continue; + } + + foreach ($pendingJobs as $schedule) { + $runningSchedule = $schedule; + + $jobConfig = isset($jobsRoot[$schedule->getJobCode()]) ? $jobsRoot[$schedule->getJobCode()] : null; + if (!$jobConfig) { + continue; + } + + $scheduledTime = strtotime($schedule->getScheduledAt()); + if ($scheduledTime > $currentTime) { + continue; + } + + try { + if ($schedule->tryLockJob()) { + $this->scheduleHelper->setPid($schedule); + $cpu_before = getrusage(); + $this->_runJob($scheduledTime, $currentTime, $jobConfig, $schedule, $groupId); + $cpu_after = getrusage(); + $this->scheduleHelper->setCpuUsage($cpu_after, $cpu_before, $schedule); + $this->scheduleHelper->setMemoryUsage($schedule); + } + } catch (\Exception $e) { + $schedule->setMessages($e->getMessage()); + $schedule->setErrorMessage($e->getMessage()); + $schedule->setErrorFile($e->getFile()); + $schedule->setErrorLine($e->getLine()); + } + $schedule->save(); + } + + $this->_generate($groupId); + $this->_cleanup($groupId); + } + } + + /** + * @param string $jobCode + * @param string $cronExpression + * @param int $timeInterval + * @param array $exists + * @return void + */ + protected function saveSchedule($jobCode, $cronExpression, $timeInterval, $exists) + { + $result = $this->jobHelper->isJobActive($jobCode); + + if ($result) { + parent::saveSchedule($jobCode, $cronExpression, $timeInterval, $exists); + } + } +} diff --git a/Setup/InstallSchema.php b/Setup/InstallSchema.php new file mode 100644 index 0000000..89b0b09 --- /dev/null +++ b/Setup/InstallSchema.php @@ -0,0 +1,95 @@ +startSetup(); + + $installer->getConnection()->addColumn($installer->getTable('cron_schedule'), 'pid', [ + 'type' => \Magento\Framework\DB\Ddl\Table::TYPE_TEXT, + 'length' => '100', + 'nullable' => true, + 'comment' => 'Process id of the cron' + ]); + + $installer->getConnection()->addColumn($installer->getTable('cron_schedule'), 'memory_usage', [ + 'type' => \Magento\Framework\DB\Ddl\Table::TYPE_DECIMAL, + 'length' => '12,2', + 'nullable' => true, + 'comment' => 'Memory Usage of the Cron' + ]); + + $installer->getConnection()->addColumn($installer->getTable('cron_schedule'), 'cpu_usage', [ + 'type' => \Magento\Framework\DB\Ddl\Table::TYPE_DECIMAL, + 'length' => '12,2', + 'nullable' => true, + 'comment' => 'CPU Usage of the Cron' + ]); + + $installer->getConnection()->addColumn($installer->getTable('cron_schedule'), 'system_usage', [ + 'type' => \Magento\Framework\DB\Ddl\Table::TYPE_DECIMAL, + 'length' => '12,2', + 'nullable' => true, + 'comment' => 'System Usage of the Cron' + ]); + + $installer->getConnection()->addColumn($installer->getTable('cron_schedule'), 'is_mail_sent', [ + 'type' => \Magento\Framework\DB\Ddl\Table::TYPE_BOOLEAN, + 'length' => 1, + 'nullable' => true, + 'comment' => 'Is mail sent for Missing Crons?' + ]); + + $installer->getConnection()->addColumn($installer->getTable('cron_schedule'), 'error_message', [ + 'type' => \Magento\Framework\DB\Ddl\Table::TYPE_TEXT, + 'length' => 500, + 'nullable' => true, + 'comment' => 'FATAL Error/ Execption Message' + ]); + + $installer->getConnection()->addColumn($installer->getTable('cron_schedule'), 'error_file', [ + 'type' => \Magento\Framework\DB\Ddl\Table::TYPE_TEXT, + 'length' => 500, + 'nullable' => true, + 'comment' => 'Error File Name' + ]); + + $installer->getConnection()->addColumn($installer->getTable('cron_schedule'), 'error_line', [ + 'type' => \Magento\Framework\DB\Ddl\Table::TYPE_TEXT, + 'length' => 50, + 'nullable' => true, + 'comment' => 'Error Line Number' + ]); + + $installer->endSetup(); + } +} diff --git a/Setup/Recurring.php b/Setup/Recurring.php new file mode 100644 index 0000000..e55258b --- /dev/null +++ b/Setup/Recurring.php @@ -0,0 +1,90 @@ +output = $output; + $explodedVersion = explode("-", $productMetaData->getVersion()); + $this->magentoVersion = $explodedVersion[0]; + } + + /** + * Copy files for specific version + * @param $data + */ + public function copyFilesForVersion($data) + { + $version = $this->magentoVersion; + $explodedVersion = explode(".", $version); + $expectedVersion = [ + $version, + $explodedVersion[0] . "." . $explodedVersion[1], + $explodedVersion[0] + ]; + + $path = str_replace("Setup" . DIRECTORY_SEPARATOR . "Recurring.php", "", __FILE__); + + foreach ($data as $file) { + $fullFile = $path . str_replace("/", DIRECTORY_SEPARATOR, $file); + $ext = pathinfo($fullFile, PATHINFO_EXTENSION); + + foreach ($expectedVersion as $v) { + $newFile = str_replace("." . $ext, "_" . $v . "." . $ext, $fullFile); + if (file_exists($newFile)) { + copy($newFile, $fullFile); + break; + } + } + } + } + + /** + * {@inheritdoc} + */ + public function install( + \Magento\Framework\Setup\SchemaSetupInterface $setup, + \Magento\Framework\Setup\ModuleContextInterface $context + ) { + + $data = [ + "Observer/ProcessCronQueueObserver.php" + ]; + $this->copyFilesForVersion($data); + } +} diff --git a/Setup/Uninstall.php b/Setup/Uninstall.php new file mode 100644 index 0000000..a0ab6a4 --- /dev/null +++ b/Setup/Uninstall.php @@ -0,0 +1,45 @@ +startSetup(); + + $uninstaller->getConnection()->dropColumn($uninstaller->getTable('cron_schedule'), 'pid', null); + $uninstaller->getConnection()->dropColumn($uninstaller->getTable('cron_schedule'), 'memory_usage', null); + $uninstaller->getConnection()->dropColumn($uninstaller->getTable('cron_schedule'), 'cpu_usage', null); + $uninstaller->getConnection()->dropColumn($uninstaller->getTable('cron_schedule'), 'system_usage', null); + $uninstaller->getConnection()->dropColumn($uninstaller->getTable('cron_schedule'), 'is_mail_sent', null); + + $uninstaller->endSetup(); + } +} diff --git a/Ui/Component/Listing/Column/Actions.php b/Ui/Component/Listing/Column/Actions.php new file mode 100644 index 0000000..acc8701 --- /dev/null +++ b/Ui/Component/Listing/Column/Actions.php @@ -0,0 +1,86 @@ +urlBuilder = $urlBuilder; + parent::__construct($context, $uiComponentFactory, $components, $data); + } + + /** + * Prepare Data Source + * + * @param array $dataSource + * @return array + */ + public function prepareDataSource(array $dataSource) + { + if (isset($dataSource['data']['items'])) { + foreach ($dataSource['data']['items'] as &$item) { + $item[$this->getData('name')]['edit'] = [ + 'href' => $this->urlBuilder->getUrl( + 'cronscheduler/job/edit', + ['job_code' => $item['code']] + ), + 'label' => __('Edit'), + 'hidden' => false, + ]; + $item[$this->getData('name')]['delete'] = [ + 'href' => $this->urlBuilder->getUrl( + 'cronscheduler/job/delete', + ['job_code' => $item['code'],'group' => $item['group']] + ), + 'label' => __('Delete'), + 'hidden' => false, + 'confirm' => [ + 'title' => __('Delete job'), + 'message' => __('Are you sure to delete the job?'), + ], + ]; + } + } + + return $dataSource; + } +} diff --git a/Ui/DataProvider/Form/CronJobDataProvider.php b/Ui/DataProvider/Form/CronJobDataProvider.php new file mode 100644 index 0000000..44150a3 --- /dev/null +++ b/Ui/DataProvider/Form/CronJobDataProvider.php @@ -0,0 +1,99 @@ +collection = $collectionFactory->create(); + $this->jobHelper = $jobHelper; + $this->request = $request; + } + + /** + * {@inheritdoc} + * @return array + */ + public function getData() + { + if ($this->loadedData) { + return $this->loadedData; + } + + $this->loadedData = $this->jobHelper->getJobData(); + $jobCode = $this->request->getParam('job_code'); + if (!empty($jobCode)) { + if (isset($this->loadedData[$jobCode])) { + $this->loadedData[$jobCode]['oldexpressionvalue'] = $this->loadedData[$jobCode]['schedule']; + } + } + + return $this->loadedData; + } + + /** + * {@inheritdoc} + * @return array + */ + public function getMeta() + { + $meta = parent::getMeta(); + return $meta; + } +} diff --git a/Ui/DataProvider/Form/Options.php b/Ui/DataProvider/Form/Options.php new file mode 100644 index 0000000..a1ffc1a --- /dev/null +++ b/Ui/DataProvider/Form/Options.php @@ -0,0 +1,42 @@ +options === null) { + $this->options = [ + ["label" => __('Enable'), "value" => 1], + ["label" => __('Disable'), "value" => 0] + ]; + } + return $this->options; + } +} diff --git a/Ui/DataProvider/Group/Options.php b/Ui/DataProvider/Group/Options.php new file mode 100644 index 0000000..71123ac --- /dev/null +++ b/Ui/DataProvider/Group/Options.php @@ -0,0 +1,55 @@ +cronConfig = $cronConfig; + } + + /** + * Get all options available + * @return array + */ + public function toOptionArray() + { + if ($this->options === null) { + $configJobs = $this->cronConfig->getJobs(); + foreach (array_keys($configJobs) as $group) { + $this->options[] = [ + "label" => $group, "value" => $group + ]; + } + } + return $this->options; + } +} diff --git a/Ui/DataProvider/JobProvider.php b/Ui/DataProvider/JobProvider.php new file mode 100644 index 0000000..3c6d6e6 --- /dev/null +++ b/Ui/DataProvider/JobProvider.php @@ -0,0 +1,174 @@ +directoryRead = $directoryRead; + $this->directoryList = $directoryList; + $this->jobHelper = $jobHelper; + parent::__construct($name, $primaryFieldName, $requestFieldName, $meta, $data); + } + + /** + * Set the limit of the collection + * @param int $offset + * @param int $size + */ + public function setLimit( + $offset, + $size + ) { + $this->size = $size; + $this->offset = $offset; + } + + /** + * Get the collection + * @return array + */ + public function getData() + { + $data = array_values($this->jobHelper->getJobData()); + + $totalRecords = count($data); + + #sorting + $sortField = $this->sortField; + $sortDir = $this->sortDir; + usort($data, function ($a, $b) use ($sortField, $sortDir) { + if ($sortDir == "asc") { + return $a[$sortField] > $b[$sortField]; + } else { + return $a[$sortField] < $b[$sortField]; + } + }); + + #filters + foreach ($this->likeFilters as $column => $value) { + $data = array_filter($data, function ($item) use ($column, $value) { + return stripos($item[$column], $value) !== false; + }); + } + + #pagination + $data = array_slice($data, ($this->offset - 1) * $this->size, $this->size); + + return [ + 'totalRecords' => $totalRecords, + 'items' => $data, + ]; + } + + /** + * Add filters to the collection + * @param \Magento\Framework\Api\Filter $filter + */ + public function addFilter(\Magento\Framework\Api\Filter $filter) + { + if ($filter->getConditionType() == "like") { + $this->likeFilters[$filter->getField()] = substr($filter->getValue(), 1, -1); + } elseif ($filter->getConditionType() == "eq") { + $this->likeFilters[$filter->getField()] = $filter->getValue(); + } elseif ($filter->getConditionType() == "gteq") { + $this->rangeFilters[$filter->getField()]['from'] = $filter->getValue(); + } elseif ($filter->getConditionType() == "lteq") { + $this->rangeFilters[$filter->getField()]['to'] = $filter->getValue(); + } + } + + /** + * Set the order of the collection + * @param string $field + * @param string $direction + */ + public function addOrder( + $field, + $direction + ) { + $this->sortField = $field; + $this->sortDir = strtolower($direction); + } +} diff --git a/Ui/DataProvider/ScheduleProvider.php b/Ui/DataProvider/ScheduleProvider.php new file mode 100644 index 0000000..699ea63 --- /dev/null +++ b/Ui/DataProvider/ScheduleProvider.php @@ -0,0 +1,48 @@ +collection = $collectionFactory->create(); + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..3930b7e --- /dev/null +++ b/composer.json @@ -0,0 +1,31 @@ +{ + "name": "kiwicommerce/magento2-cron-scheduler", + "description": "Magento 2 - Cron Scheduler", + "type": "magento2-module", + "version": "1.0.0", + "license": [ + "OSL-3.0" + ], + "authors": [ + { + "name": "Kiwi Commerce Ltd", + "email": "hello@kiwicommerce.co.uk", + "homepage": "https://kiwicommerce.co.uk/", + "role": "Leader" + } + ], + "repositories": [ + { + "type": "git", + "url": "https://github.com/kiwicommerce/magento2-cron-scheduler" + } + ], + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "KiwiCommerce\\CronScheduler\\": "" + } + } +} diff --git a/etc/acl.xml b/etc/acl.xml new file mode 100644 index 0000000..845d4aa --- /dev/null +++ b/etc/acl.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/etc/adminhtml/menu.xml b/etc/adminhtml/menu.xml new file mode 100644 index 0000000..1a9b2af --- /dev/null +++ b/etc/adminhtml/menu.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + diff --git a/etc/adminhtml/routes.xml b/etc/adminhtml/routes.xml new file mode 100644 index 0000000..a1ec13e --- /dev/null +++ b/etc/adminhtml/routes.xml @@ -0,0 +1,22 @@ + + + + + + + + + \ No newline at end of file diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml new file mode 100644 index 0000000..3d63c07 --- /dev/null +++ b/etc/adminhtml/system.xml @@ -0,0 +1,68 @@ + + + + + + + +
+ + kiwicommerce + KiwiCommerce_CronScheduler::cronscheduler_configuration + + + + + Allow/disallow to get email in case of cron execution error or missed cron. + Magento\Config\Model\Config\Source\Yesno + + + + Enter comma separated email address to get email on multiple addresses. + required-entry validate-comma-separated-emails + + 1 + + + + + required-entry + +
+* * * * *
+| | | | |
+| | | | +---- Day of the Week   (range: 0-6, 1 standing for Monday)
+| | | +------ Month of the Year (range: 1-12)
+| | +-------- Day of the Month  (range: 1-31)
+| +---------- Hour              (range: 0-23)
++------------ Minute            (range: 0-59)
+Example: 0 0 * * * Daily at midnight
+
+ ]]>
+ + 1 + +
+ + + Allow/disallow to show top running jobs on dashboard. + Magento\Config\Model\Config\Source\Yesno + +
+
+
+
\ No newline at end of file diff --git a/etc/config.xml b/etc/config.xml new file mode 100644 index 0000000..4244766 --- /dev/null +++ b/etc/config.xml @@ -0,0 +1,26 @@ + + + + + + + 0 + 0 + 0 10 * * * + + + + \ No newline at end of file diff --git a/etc/crontab.xml b/etc/crontab.xml new file mode 100644 index 0000000..d424e5c --- /dev/null +++ b/etc/crontab.xml @@ -0,0 +1,30 @@ + + + + + + * * * * * + + + + + */15 * * * * + + + cronscheduler/general/schedule + + + \ No newline at end of file diff --git a/etc/di.xml b/etc/di.xml new file mode 100644 index 0000000..4b34a7f --- /dev/null +++ b/etc/di.xml @@ -0,0 +1,18 @@ + + + + + \ No newline at end of file diff --git a/etc/email_templates.xml b/etc/email_templates.xml new file mode 100644 index 0000000..2babf89 --- /dev/null +++ b/etc/email_templates.xml @@ -0,0 +1,19 @@ + + + +