diff --git a/CHANGELOG.md b/CHANGELOG.md index f9244a38..f3e032ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -186,6 +186,7 @@ - Added PrestaShop 9 compatibility - Fixed issue with price rule "All" - Fixed other minor issues +- Improved Logs section by collapsing request/response into a modal viewer to fix table overflow on long URLs ## [3.3.1] - Fixed incorrect COD label amount when discount code is applied @@ -199,4 +200,4 @@ - Fixed automatic PUDO point pre-selection in LIST mode - Fixed PUDO shipment CSS styling - Added timeframes to shipment request -- Fixed DPDBaltics menu item disappearing from sidebar when navigating to module pages \ No newline at end of file +- Fixed DPDBaltics menu item disappearing from sidebar when navigating to module pages diff --git a/controllers/admin/AdminDPDBalticsLogsController.php b/controllers/admin/AdminDPDBalticsLogsController.php index 1924b2aa..e289518f 100644 --- a/controllers/admin/AdminDPDBalticsLogsController.php +++ b/controllers/admin/AdminDPDBalticsLogsController.php @@ -18,8 +18,9 @@ * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 */ +use Invertus\dpdBaltics\Config\Config; use Invertus\dpdBaltics\Controller\AbstractAdminController; -use Invertus\dpdBaltics\Logger\Logger; +use Invertus\dpdBaltics\Infrastructure\Bootstrap\ModuleTabs; require_once dirname(__DIR__).'/../vendor/autoload.php'; @@ -29,6 +30,11 @@ class AdminDPDBalticsLogsController extends AbstractAdminController { + const FILE_NAME = 'AdminDPDBalticsLogsController'; + + const LOG_INFORMATION_TYPE_REQUEST = 'request'; + const LOG_INFORMATION_TYPE_RESPONSE = 'response'; + public function __construct() { $this->className = 'DPDProduct'; @@ -46,37 +52,280 @@ public function initToolbar() unset($this->toolbar_btn['new']); } + public function setMedia($isNewTheme = false) + { + parent::setMedia($isNewTheme); + + Media::addJsDef([ + 'dpdbaltics' => [ + 'logsUrl' => $this->context->link->getAdminLink(ModuleTabs::ADMIN_LOGS_CONTROLLER), + ], + ]); + + $this->addCSS($this->module->getPathUri() . 'views/css/admin/logs_tab.css'); + $this->addJS($this->module->getPathUri() . 'views/js/admin/log.js'); + } + + public function printRequestButton($request, $data) + { + return $this->getDisplayButton($data['id_dpd_log'], $request, self::LOG_INFORMATION_TYPE_REQUEST); + } + + public function printResponseButton($response, $data) + { + return $this->getDisplayButton($data['id_dpd_log'], $response, self::LOG_INFORMATION_TYPE_RESPONSE); + } + + public function printSeverity($severity, $data) + { + $level = strtolower((string) $severity); + $levelMap = [ + 'emergency' => 1, 'alert' => 1, 'critical' => 1, 'error' => 1, + 'warning' => 2, + 'notice' => 3, 'info' => 3, + 'debug' => 4, + ]; + $num = isset($levelMap[$level]) ? $levelMap[$level] : null; + $cssClass = $num ? 'dpd-log-severity dpd-log-severity-' . $num : 'dpd-log-severity'; + $label = $level !== '' ? ucfirst($level) : '--'; + + return sprintf( + '%s', + htmlspecialchars($cssClass, ENT_QUOTES, 'UTF-8'), + $num ? sprintf('%d · %s', $num, htmlspecialchars($label, ENT_QUOTES, 'UTF-8')) : htmlspecialchars($label, ENT_QUOTES, 'UTF-8') + ); + } + + public function printMessage($message, $data) + { + if ($message === null || $message === '') { + return '--'; + } + $value = (string) $message; + $decoded = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded) && isset($decoded['message'])) { + $value = (string) $decoded['message']; + } + $clean = trim(preg_replace('/\s+/', ' ', strip_tags($value))); + if (function_exists('mb_strlen') && mb_strlen($clean) > 90) { + $clean = mb_substr($clean, 0, 87) . '...'; + } + return htmlspecialchars($clean, ENT_QUOTES, 'UTF-8'); + } + + public function printContext($context, $data) + { + if ($context === null || $context === '') { + return '--'; + } + $endpoint = ''; + $decoded = json_decode((string) $context, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded) && !empty($decoded['endpoint'])) { + $endpoint = (string) $decoded['endpoint']; + } else { + $endpoint = (string) $context; + } + $endpoint = strtok($endpoint, '?'); + $endpoint = $endpoint !== false ? basename($endpoint) : ''; + if ($endpoint === '') { + return '--'; + } + return htmlspecialchars($endpoint, ENT_QUOTES, 'UTF-8'); + } + + public function processExport($textDelimiter = '"') + { + if (ob_get_level() && ob_get_length() > 0) { + ob_clean(); + } + + $fileName = sprintf('dpdbaltics_logs_%s.csv', date('Y-m-d_His')); + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Disposition: attachment; filename=' . $fileName); + header('Cache-Control: no-store, no-cache'); + + $fd = fopen('php://output', 'wb'); + + $storeInfo = [ + 'PrestaShop Version' => _PS_VERSION_, + 'PHP Version' => phpversion(), + 'Module Version' => $this->module->version, + 'MySQL Version' => Db::getInstance()->getVersion(), + 'Shop URL' => $this->context->shop ? $this->context->shop->getBaseURL(true) : '', + 'Shop Name' => Configuration::get('PS_SHOP_NAME'), + ]; + + $moduleConfigurations = [ + 'Test mode' => Configuration::get(Config::SHIPMENT_TEST_MODE) ? 'Yes' : 'No', + 'API country' => Configuration::get(Config::WEB_SERVICE_COUNTRY), + 'Track logs' => Configuration::get(Config::TRACK_LOGS) ? 'Yes' : 'No', + ]; + + $psSettings = [ + 'Default country' => Configuration::get('PS_COUNTRY_DEFAULT'), + 'Default currency' => Configuration::get('PS_CURRENCY_DEFAULT'), + 'Default language' => Configuration::get('PS_LANG_DEFAULT'), + 'Round mode' => Configuration::get('PS_PRICE_ROUND_MODE'), + 'Round type' => Configuration::get('PS_ROUND_TYPE'), + 'PHP memory limit' => ini_get('memory_limit'), + ]; + + fputcsv($fd, array_keys($storeInfo), ';', $textDelimiter); + fputcsv($fd, array_values($storeInfo), ';', $textDelimiter); + fputcsv($fd, [], ';', $textDelimiter); + + $moduleConfigInfo = "**Module configurations:**\n"; + foreach ($moduleConfigurations as $key => $value) { + $moduleConfigInfo .= '- ' . $key . ': ' . $value . "\n"; + } + + $psSettingsInfo = "**Prestashop settings:**\n"; + foreach ($psSettings as $key => $value) { + $psSettingsInfo .= '- ' . $key . ': ' . $value . "\n"; + } + + fputcsv($fd, [$moduleConfigInfo], ';', $textDelimiter); + fputcsv($fd, [$psSettingsInfo], ';', $textDelimiter); + fputcsv($fd, [], ';', $textDelimiter); + + fputcsv($fd, [ + $this->module->l('ID', self::FILE_NAME), + $this->module->l('Severity', self::FILE_NAME), + $this->module->l('Message', self::FILE_NAME), + $this->module->l('Request', self::FILE_NAME), + $this->module->l('Response', self::FILE_NAME), + $this->module->l('Context', self::FILE_NAME), + $this->module->l('Date', self::FILE_NAME), + ], ';', $textDelimiter); + + $rows = Db::getInstance()->executeS( + 'SELECT id_dpd_log, request, response, status, date_add FROM `' . _DB_PREFIX_ . pSQL($this->table) . '` ORDER BY id_dpd_log ASC' + ); + if ($rows === false) { + $rows = []; + } + + foreach ($rows as $row) { + $message = (string) ($row['response'] ?? ''); + $decodedRes = $message !== '' ? json_decode($message, true) : null; + if (json_last_error() === JSON_ERROR_NONE && is_array($decodedRes) && isset($decodedRes['message'])) { + $message = (string) $decodedRes['message']; + } + + $endpoint = ''; + $decodedReq = isset($row['request']) ? json_decode((string) $row['request'], true) : null; + if (json_last_error() === JSON_ERROR_NONE && is_array($decodedReq) && !empty($decodedReq['endpoint'])) { + $endpoint = basename(strtok((string) $decodedReq['endpoint'], '?')); + } + + fputcsv($fd, [ + $row['id_dpd_log'], + $row['status'], + $message, + $row['request'], + $row['response'], + $endpoint, + $row['date_add'], + ], ';', $textDelimiter); + } + + fclose($fd); + exit; + } + + public function displayAjaxGetLog() + { + $logId = (int) Tools::getValue('log_id'); + $log = new DPDLog($logId); + + if (!Validate::isLoadedObject($log)) { + $this->ajaxDie(json_encode([ + 'error' => true, + 'message' => $this->module->l('No log information found.', self::FILE_NAME), + ])); + } + + $this->ajaxDie(json_encode([ + 'error' => false, + 'log' => [ + self::LOG_INFORMATION_TYPE_REQUEST => $log->request, + self::LOG_INFORMATION_TYPE_RESPONSE => $log->response, + ], + ])); + } private function initList() { $this->list_no_link = true; + $this->_select = 'a.status AS severity, a.response AS message, a.request AS context'; $this->fields_list = [ 'id_dpd_log' => [ - 'title' => $this->module->l('ID'), - 'type' => 'text', - 'havingFilter' => true + 'title' => $this->module->l('ID', self::FILE_NAME), + 'align' => 'text-center', + 'class' => 'fixed-width-xs', + 'havingFilter' => true, + ], + 'severity' => [ + 'title' => $this->module->l('Severity (1-4)', self::FILE_NAME), + 'align' => 'text-center', + 'callback' => 'printSeverity', + 'search' => false, + 'orderby' => false, + 'remove_onclick' => true, + ], + 'message' => [ + 'title' => $this->module->l('Message', self::FILE_NAME), + 'callback' => 'printMessage', + 'search' => false, + 'orderby' => false, + 'remove_onclick' => true, ], 'request' => [ - 'title' => $this->module->l('request'), - 'type' => 'text', - 'havingFilter' => true + 'title' => $this->module->l('Request', self::FILE_NAME), + 'align' => 'text-center', + 'callback' => 'printRequestButton', + 'orderby' => false, + 'search' => false, + 'remove_onclick' => true, ], 'response' => [ - 'title' => $this->module->l('response'), - 'type' => 'text', - 'havingFilter' => true + 'title' => $this->module->l('Response', self::FILE_NAME), + 'align' => 'text-center', + 'callback' => 'printResponseButton', + 'orderby' => false, + 'search' => false, + 'remove_onclick' => true, ], - 'status' => [ - 'title' => $this->module->l('status'), - 'type' => 'text', - 'havingFilter' => true + 'context' => [ + 'title' => $this->module->l('Context', self::FILE_NAME), + 'callback' => 'printContext', + 'search' => false, + 'orderby' => false, + 'remove_onclick' => true, ], 'date_add' => [ - 'title' => $this->module->l('Created date'), + 'title' => $this->module->l('Date', self::FILE_NAME), + 'align' => 'right', 'type' => 'datetime', - 'havingFilter' => true - ] + 'havingFilter' => true, + ], ]; } + + private function getDisplayButton($logId, $data, $logInformationType) + { + if (empty($data)) { + return '--'; + } + + $this->context->smarty->assign([ + 'log_id' => $logId, + 'log_information_type' => $logInformationType, + ]); + + return $this->context->smarty->fetch( + $this->module->getLocalPath() . 'views/templates/admin/logs/log_modal.tpl' + ); + } } diff --git a/src/Logger/Logger.php b/src/Logger/Logger.php index ecaa083a..e4dc9eaa 100644 --- a/src/Logger/Logger.php +++ b/src/Logger/Logger.php @@ -178,10 +178,78 @@ public function log($level, $message, array $context = []): void } $log = new DPDLog(); - $log->response = $message; - $log->request = !empty($context['request']) ? $this->logsService->hideUsernameAndPasswordFromRequest($context['request']) : null; + $log->response = $this->encodeResponse($level, $message, $context); + $log->request = !empty($context['request']) + ? $this->encodeRequest($this->logsService->hideUsernameAndPasswordFromRequest($context['request'])) + : null; $log->status = $level; $log->add(); } + + private function encodeRequest($raw): ?string + { + if ($raw === null || $raw === '') { + return null; + } + + if (is_array($raw)) { + return $this->jsonEncode($raw); + } + + $rawString = (string) $raw; + $parsed = parse_url($rawString); + + if (!$parsed || empty($parsed['scheme']) || empty($parsed['host'])) { + return $this->jsonEncode(['raw' => $rawString]); + } + + $endpoint = $parsed['scheme'] . '://' . $parsed['host']; + if (isset($parsed['path'])) { + $endpoint .= $parsed['path']; + } + + $params = []; + if (!empty($parsed['query'])) { + parse_str($parsed['query'], $params); + if (array_key_exists('password', $params)) { + $params['password'] = '***'; + } + if (array_key_exists('username', $params) && $params['username'] !== '') { + $params['username'] = '***'; + } + } + + return $this->jsonEncode([ + 'endpoint' => $endpoint, + 'params' => $params, + ]); + } + + private function encodeResponse($level, $message, array $context): ?string + { + $payload = [ + 'level' => (string) $level, + ]; + + $decoded = is_string($message) ? json_decode($message, true) : null; + if (json_last_error() === JSON_ERROR_NONE && (is_array($decoded) || is_object($decoded))) { + $payload['body'] = $decoded; + } else { + $payload['message'] = is_scalar($message) || $message === null ? (string) $message : $message; + } + + $extra = $context; + unset($extra['request']); + if (!empty($extra)) { + $payload['context'] = $extra; + } + + return $this->jsonEncode($payload); + } + + private function jsonEncode($value): string + { + return json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } } diff --git a/views/css/admin/logs_tab.css b/views/css/admin/logs_tab.css new file mode 100644 index 00000000..4c41b115 --- /dev/null +++ b/views/css/admin/logs_tab.css @@ -0,0 +1,174 @@ +/** + * NOTICE OF LICENSE + * + * @author INVERTUS, UAB www.invertus.eu + * @copyright Copyright (c) permanent, INVERTUS, UAB + * @license Addons PrestaShop license limitation + * @see /LICENSE + * + * International Registered Trademark & Property of INVERTUS, UAB + */ +table.dpd_log td { + word-break: break-word; + vertical-align: middle; +} + +.button { + cursor: pointer; +} + +.dpd-log-severity { + display: inline-block; + padding: 3px 8px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + line-height: 1; + color: #ffffff; + background: #6b7280; + white-space: nowrap; +} + +.dpd-log-severity-1 { + background: #dc3545; +} + +.dpd-log-severity-2 { + background: #f0ad4e; +} + +.dpd-log-severity-3 { + background: #0d6efd; +} + +.dpd-log-severity-4 { + background: #6c757d; +} + +.log-modal-overlay { + transition: opacity 0.2s ease-out; + pointer-events: none; + background: rgba(15, 23, 42, 0.8); + position: fixed; + opacity: 0; + bottom: 0; + right: 0; + left: 0; + top: 0; + z-index: 1040; +} + +.modal.open .log-modal-overlay { + pointer-events: all; + opacity: 0.5; +} + +.log-modal-window { + position: relative; + width: 90%; + max-width: 880px; + margin: 5vh auto; + + background: #ffffff; + border-radius: 0.5em; + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2); + pointer-events: all; + text-align: left; + max-height: 90vh; + min-height: 50vh; + display: flex; + flex-direction: column; + + overflow: hidden; + z-index: 1050; +} + +.modal.open { + display: block; +} + +.log-modal-title { + position: relative; + color: #111827; + padding: 14px 48px; + border-bottom: solid 1px #e5e7eb; + pointer-events: all; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.log-modal-title-text { + margin: 0; + font-size: 15px; + font-weight: 600; + text-align: center; +} + +.log-modal-close { + position: absolute; + top: 50%; + right: 14px; + transform: translateY(-50%); + background: transparent; + border: 0; + font-size: 24px; + line-height: 1; + color: #6b7280; + padding: 0 6px; + cursor: pointer; +} + +.log-modal-close:hover { + color: #111827; +} + +.log-modal-content { + padding: 18px; + flex: 1 1 auto; + overflow: auto; +} + +.log-modal-content-data { + white-space: pre-wrap; + word-break: break-all; + margin: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; + color: #111827; + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 6px; + padding: 14px 16px; +} + +.log-modal-content-spinner { + min-height: 200px; +} + +.log-modal-content-spinner:not(.hidden) { + display: flex; + justify-content: center; + align-items: center; +} + +.log-modal-content-spinner::after { + content: ""; + width: 40px; + height: 40px; + border: 2px solid #f3f3f3; + border-top: 3px solid #DC0032; + border-radius: 100%; + will-change: transform; + animation: dpd-log-spin 1s infinite linear; +} + +@keyframes dpd-log-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/views/js/admin/log.js b/views/js/admin/log.js new file mode 100644 index 00000000..285108ef --- /dev/null +++ b/views/js/admin/log.js @@ -0,0 +1,96 @@ +/** + * NOTICE OF LICENSE + * + * @author INVERTUS, UAB www.invertus.eu + * @copyright Copyright (c) permanent, INVERTUS, UAB + * @license Addons PrestaShop license limitation + * @see /LICENSE + * + * International Registered Trademark & Property of INVERTUS, UAB + */ +$(document).ready(function () { + $(document).on('click', '.log-modal-overlay, .js-log-modal-close', function (event) { + $('.modal.open').removeClass('open'); + event.preventDefault(); + }); + + $(document).on('keydown', function (event) { + if (event.key === 'Escape') { + $('.modal.open').removeClass('open'); + } + }); + + $('.js-log-button').on('click', function (event) { + var logId = $(this).data('log-id'); + var informationType = $(this).data('information-type'); + + $('#' + $(this).data('target')).addClass('open'); + + var $contentData = $('#log-modal-' + logId + '-' + informationType + ' .log-modal-content-data'); + + if (!$contentData.hasClass('hidden')) { + return; + } + + var $spinner = $('#log-modal-' + logId + '-' + informationType + ' .log-modal-content-spinner'); + $spinner.removeClass('hidden'); + + $.ajax({ + type: 'POST', + url: dpdbaltics.logsUrl, + data: { + ajax: true, + action: 'getLog', + log_id: logId + } + }) + .then(function (response) { return jQuery.parseJSON(response); }) + .then(function (data) { + $spinner.addClass('hidden'); + + if (data.error) { + $contentData.removeClass('hidden').text(data.message || ''); + return; + } + + $('#log-modal-' + logId + '-request .log-modal-content-data').removeClass('hidden').text(formatLogPayload(data.log.request)); + $('#log-modal-' + logId + '-response .log-modal-content-data').removeClass('hidden').text(formatLogPayload(data.log.response)); + }); + }); +}); + +function formatLogPayload(payload) { + if (payload === null || typeof payload === 'undefined' || payload === '') { + return ''; + } + + try { + return JSON.stringify(JSON.parse(payload), null, 2); + } catch (e) { + // not JSON + } + + var queryIndex = payload.indexOf('?'); + if (queryIndex !== -1 && payload.indexOf('&') !== -1) { + var base = payload.substring(0, queryIndex); + var query = payload.substring(queryIndex + 1); + var parts = query.split('&').map(function (part) { + var eq = part.indexOf('='); + if (eq === -1) { + return decodeURIComponentSafe(part); + } + return decodeURIComponentSafe(part.substring(0, eq)) + ' = ' + decodeURIComponentSafe(part.substring(eq + 1)); + }); + return base + '\n\n' + parts.join('\n'); + } + + return payload; +} + +function decodeURIComponentSafe(value) { + try { + return decodeURIComponent(value.replace(/\+/g, ' ')); + } catch (e) { + return value; + } +} diff --git a/views/templates/admin/logs/index.php b/views/templates/admin/logs/index.php new file mode 100644 index 00000000..58b8b473 --- /dev/null +++ b/views/templates/admin/logs/index.php @@ -0,0 +1,30 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + + +header('Expires: Mon, 26 Jul 1997 05:00:00 GMT'); +header('Last-Modified: ' . gmdate('D, d M Y H:i:s') . ' GMT'); + +header('Cache-Control: no-store, no-cache, must-revalidate'); +header('Cache-Control: post-check=0, pre-check=0', false); +header('Pragma: no-cache'); + +header('Location: ../'); +exit; diff --git a/views/templates/admin/logs/log_modal.tpl b/views/templates/admin/logs/log_modal.tpl new file mode 100644 index 00000000..f9b392af --- /dev/null +++ b/views/templates/admin/logs/log_modal.tpl @@ -0,0 +1,41 @@ +{* +* NOTICE OF LICENSE +* +* @author INVERTUS, UAB www.invertus.eu +* @copyright Copyright (c) permanent, INVERTUS, UAB +* @license Addons PrestaShop license limitation +* @see /LICENSE +* +* International Registered Trademark & Property of INVERTUS, UAB +*} +
+ {l s='View' mod='dpdbaltics'} +
+ +