From bcdf1229d377a9fc8482d7b229012a05cca6ccc9 Mon Sep 17 00:00:00 2001 From: Max Gasumyants Date: Tue, 12 May 2026 17:40:35 +0100 Subject: [PATCH 1/2] CU-869d95pzd Add connectivity diagnostics and multi-host cloud probes Co-authored-by: Cursor --- assets/js/admin-connect.js | 46 +++++ composer.json | 3 + scripts/check-pro-oawms-connection.php | 82 ++++++++ scripts/check-pro-oawms-connection.sh | 54 +++++ src/Admin/ConnectivityProbe.php | 271 +++++++++++++++++++++++++ src/ConnectService.php | 76 +++++-- src/SettingsPage.php | 35 ++++ 7 files changed, 549 insertions(+), 18 deletions(-) create mode 100644 scripts/check-pro-oawms-connection.php create mode 100755 scripts/check-pro-oawms-connection.sh create mode 100644 src/Admin/ConnectivityProbe.php diff --git a/assets/js/admin-connect.js b/assets/js/admin-connect.js index 334a019..f213d81 100644 --- a/assets/js/admin-connect.js +++ b/assets/js/admin-connect.js @@ -2,6 +2,52 @@ 'use strict'; $(function () { + const diagBtn = $('#octavawms-connectivity-run'); + const diagOut = $('#octavawms-connectivity-output'); + const diagCfg = window.octavawmsConnect; + if ( + diagBtn.length && + diagOut.length && + diagCfg && + diagCfg.ajaxUrl && + diagCfg.connectivityProbeAction && + diagCfg.connectivityProbeNonce + ) { + const spDiag = $('#octavawms-connectivity-spinner'); + const strings = diagCfg.strings || {}; + diagBtn.on('click', function () { + diagBtn.prop('disabled', true); + spDiag.css('visibility', 'visible'); + diagOut.text(strings.connectivityRunning || 'Running diagnostics…'); + $.post( + diagCfg.ajaxUrl, + { + action: diagCfg.connectivityProbeAction, + security: diagCfg.connectivityProbeNonce, + }, + function (res) { + if (res && res.success && res.data && typeof res.data.report === 'string') { + diagOut.text(res.data.report); + return; + } + diagOut.text( + (res && res.data && res.data.message) || + strings.connectivityFailed || + 'Diagnostics failed.' + ); + }, + 'json' + ) + .fail(function () { + diagOut.text(strings.connectivityFailed || 'Diagnostics failed.'); + }) + .always(function () { + spDiag.css('visibility', 'hidden'); + diagBtn.prop('disabled', false); + }); + }); + } + const btn = $('#octavawms-connect-btn'); const panelBtn = $('#octavawms-panel-login-btn'); if (!btn.length && !panelBtn.length) { diff --git a/composer.json b/composer.json index 9c53413..8cc68ac 100644 --- a/composer.json +++ b/composer.json @@ -23,10 +23,13 @@ "scripts": { "php-lint": [ "php -l octavawms-woocommerce.php", + "php -l scripts/check-pro-oawms-connection.php", "php -l src/Activation.php", "php -l src/AdminLabelActions.php", + "php -l src/Admin/ConnectivityProbe.php", "php -l src/Admin/LabelAjax.php", "php -l src/Admin/LabelMetaBox.php", + "php -l src/Admin/SettingsAjax.php", "php -l src/Api/BackendApiClient.php", "php -l src/Api/LabelService.php", "php -l src/ConnectService.php", diff --git a/scripts/check-pro-oawms-connection.php b/scripts/check-pro-oawms-connection.php new file mode 100644 index 0000000..5044a08 --- /dev/null +++ b/scripts/check-pro-oawms-connection.php @@ -0,0 +1,82 @@ +/dev/null 2>&1; then + host="${BASE_URL#*://}" + host="${host%%/*}" + echo "=== DNS (dig +short ${host}) ===" + dig +short "${host}" A || true + dig +short "${host}" AAAA || true + echo +fi + +echo "Done." diff --git a/src/Admin/ConnectivityProbe.php b/src/Admin/ConnectivityProbe.php new file mode 100644 index 0000000..be67e24 --- /dev/null +++ b/src/Admin/ConnectivityProbe.php @@ -0,0 +1,271 @@ + + */ + public const STANDARD_CLOUD_BASES_FOR_DIAGNOSTICS = [ + 'https://pro.oawms.com', + 'https://alpha.orderadmin.eu', + 'https://api.octavawms.com', + ]; + + /** + * Probe one HTTPS API base only (standalone script single-URL mode). + */ + public static function buildReport( + string $baseUrl, + int $connectTimeout = self::DEFAULT_CONNECT_TIMEOUT, + int $maxTime = self::DEFAULT_MAX_TIME + ): string { + return self::buildProbeBodyForSingleBase( + self::canonicalHttpsBase(trim($baseUrl)), + $connectTimeout, + $maxTime + ) + . "\nDone.\n"; + } + + /** + * Probe the store's resolved API host (when non-empty) plus every {@see STANDARD_CLOUD_BASES_FOR_DIAGNOSTICS}. + * Duplicates (same scheme+host as a standard endpoint) are skipped. + */ + public static function buildFullDiagnosticsReport( + ?string $configuredIntegrationBase, + int $connectTimeout = self::DEFAULT_CONNECT_TIMEOUT, + int $maxTime = self::DEFAULT_MAX_TIME + ): string { + $blocks = []; + $seenHosts = []; + + $append = static function ( + string $roleLabel, + string $baseNorm + ) use (&$blocks, &$seenHosts, $connectTimeout, $maxTime): void { + if ($baseNorm === '') { + return; + } + $hk = self::hostKeyFromBaseUrl($baseNorm); + if ($hk === '') { + return; + } + if (isset($seenHosts[$hk])) { + return; + } + $seenHosts[$hk] = true; + + $hostDisplay = ''; + $p = parse_url($baseNorm . '/'); + if (is_array($p) && isset($p['host']) && is_string($p['host'])) { + $hostDisplay = $p['host']; + } + + $blocks[] = + sprintf( + '========================================== HOST: %s%s', + $hostDisplay !== '' ? $hostDisplay : $hk, + $roleLabel !== '' ? ' — ' . $roleLabel : '' + ) + . "\n" + . 'Base URL: ' . $baseNorm + . "\n==========================================" + . "\n\n" + . self::buildProbeBodyForSingleBase($baseNorm, $connectTimeout, $maxTime); + }; + + $cfg = ''; + if ($configuredIntegrationBase !== null && trim($configuredIntegrationBase) !== '') { + $cfg = self::canonicalHttpsBase(trim($configuredIntegrationBase)); + } + if ($cfg !== '') { + $append('Integration base (configured)', $cfg); + } + + foreach (self::STANDARD_CLOUD_BASES_FOR_DIAGNOSTICS as $url) { + $append('Standard cloud', self::canonicalHttpsBase(trim((string) $url))); + } + + return implode("\n\n" . str_repeat('-', 56) . "\n\n", $blocks) . "\n\nDone.\n"; + } + + /** HTTP(S) canonical base URL; empty if invalid or missing https host. */ + private static function canonicalHttpsBase(string $url): string + { + if ($url === '') { + return ''; + } + $url = rtrim(preg_replace('#\s+#', '', $url) ?? '', '/'); + $parts = parse_url($url); + $schemeRaw = isset($parts['scheme']) ? strtolower((string) $parts['scheme']) : ''; + $hostRaw = isset($parts['host']) ? strtolower((string) $parts['host']) : ''; + + if ($schemeRaw === '' || $hostRaw === '') { + return ''; + } + $scheme = in_array($schemeRaw, ['https', 'http'], true) ? $schemeRaw : 'https'; + + return $scheme . '://' . $hostRaw; + } + + private static function hostKeyFromBaseUrl(string $canonicalHttpsBaseUrl): string + { + $p = parse_url($canonicalHttpsBaseUrl . '/'); + if (! is_array($p) || ! isset($p['scheme'], $p['host'])) { + return ''; + } + + return strtolower((string) $p['scheme']) . '://' . strtolower((string) $p['host']); + } + + private static function buildProbeBodyForSingleBase( + string $baseUrl, + int $connectTimeout, + int $maxTime + ): string { + $lines = []; + + $parts = parse_url($baseUrl . '/'); + $host = is_array($parts) && isset($parts['host']) && is_string($parts['host']) ? $parts['host'] : ''; + + $lines[] = 'Probe URL base: ' . $baseUrl; + $lines[] = sprintf('limits: connect_timeout=%ds max_time=%ds', $connectTimeout, $maxTime); + $lines[] = ''; + + self::curlProbeAppend( + $lines, + 'HEAD apps/woocommerce/connect', + $baseUrl . '/apps/woocommerce/connect', + 'HEAD', + $connectTimeout, + $maxTime, + true + ); + + self::curlProbeAppend( + $lines, + 'GET root', + $baseUrl . '/', + 'GET', + $connectTimeout, + $maxTime, + false + ); + + $lines[] = '=== DNS ==='; + if ($host !== '') { + $resolved = @gethostbyname($host); + if (is_string($resolved) && $resolved !== '' && $resolved !== $host) { + $lines[] = 'gethostbyname: ' . $resolved; + } else { + $lines[] = 'gethostbyname: (no IPv4)'; + } + if (function_exists('dns_get_record')) { + foreach (@dns_get_record($host, DNS_A) ?: [] as $row) { + if (is_array($row) && ($row['type'] ?? '') === 'A' && isset($row['ip'])) { + $lines[] = 'A: ' . $row['ip']; + } + } + foreach (@dns_get_record($host, DNS_AAAA) ?: [] as $row) { + if (is_array($row) && ($row['type'] ?? '') === 'AAAA' && isset($row['ipv6'])) { + $lines[] = 'AAAA: ' . $row['ipv6']; + } + } + } + } else { + $lines[] = '(could not parse host)'; + } + + $lines[] = ''; + + return implode("\n", $lines); + } + + /** + * @param list $lines mutates + */ + private static function curlProbeAppend( + array &$lines, + string $label, + string $url, + string $method, + int $connectTimeout, + int $maxTime, + bool $footnoteOnConnectHint + ): void { + $lines[] = '=== ' . $label . ' ==='; + + if (! function_exists('curl_init')) { + $lines[] = 'Skipped: php-curl not available.'; + $lines[] = ''; + + return; + } + + $ch = curl_init($url); + if ($ch === false) { + $lines[] = 'curl_init failed'; + $lines[] = ''; + + return; + } + + $opts = [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => false, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_CONNECTTIMEOUT => max(1, $connectTimeout), + CURLOPT_TIMEOUT => max(1, $maxTime), + ]; + if ($method === 'HEAD') { + $opts[CURLOPT_NOBODY] = true; + } else { + $opts[CURLOPT_HTTPGET] = true; + } + curl_setopt_array($ch, $opts); + + $ok = curl_exec($ch); + $errno = curl_errno($ch); + $err = $errno !== 0 ? curl_error($ch) : ''; + + $code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $nw = (float) curl_getinfo($ch, CURLINFO_NAMELOOKUP_TIME); + $conn = (float) curl_getinfo($ch, CURLINFO_CONNECT_TIME); + $tls = (float) curl_getinfo($ch, CURLINFO_APPCONNECT_TIME); + $ttfb = (float) curl_getinfo($ch, CURLINFO_STARTTRANSFER_TIME); + $tot = (float) curl_getinfo($ch, CURLINFO_TOTAL_TIME); + curl_close($ch); + + if ($errno !== 0 || $ok === false) { + $lines[] = sprintf('curl error %s: %s', (string) $errno, $err); + } + $lines[] = sprintf( + 'status:%s namelookup:%.6fs connect:%.6fs appconnect:%.6fs starttransfer:%.6fs total:%.6fs', + $code > 0 ? (string) $code : 'n/a', + $nw, + $conn, + $tls > 0 ? $tls : 0.0, + $ttfb, + $tot + ); + + if ($footnoteOnConnectHint) { + $lines[] = '(405 / 401 on this path usually still means the host is reachable)'; + } + $lines[] = ''; + } +} diff --git a/src/ConnectService.php b/src/ConnectService.php index d0972ad..5ce153e 100644 --- a/src/ConnectService.php +++ b/src/ConnectService.php @@ -4,6 +4,7 @@ namespace OctavaWMS\WooCommerce; +use OctavaWMS\WooCommerce\Admin\ConnectivityProbe; use OctavaWMS\WooCommerce\Admin\SettingsAjax; use OctavaWMS\WooCommerce\Api\BackendApiClient; @@ -16,10 +17,14 @@ class ConnectService /** @see handleAjaxPanelLoginUrl() */ public const PANEL_LOGIN_NONCE_ACTION = 'octavawms_panel_login'; + /** @see handleAjaxConnectivityProbe() Dedicated action so diagnostics does not reuse carrier-matrix AJAX. */ + public const CONNECTIVITY_PROBE_ACTION = 'octavawms_connectivity_probe'; + public function register(): void { add_action('wp_ajax_' . self::ACTION, [$this, 'handleAjaxConnect']); add_action('wp_ajax_' . self::PANEL_LOGIN_ACTION, [$this, 'handleAjaxPanelLoginUrl']); + add_action('wp_ajax_' . self::CONNECTIVITY_PROBE_ACTION, [$this, 'handleAjaxConnectivityProbe']); // Priority 20: run after WooCommerce (priority 10) has registered selectWoo. add_action('admin_enqueue_scripts', [$this, 'maybeEnqueueConnectScript'], 20); } @@ -71,23 +76,6 @@ public function maybeEnqueueConnectScript(string $hook): void $script_url = plugins_url($script_rel, $plugin_root . '/octavawms-woocommerce.php'); $version = is_readable($script_path) ? (string) filemtime($script_path) : '1.0.0'; - wp_register_script('octavawms-admin-connect', $script_url, ['jquery'], $version, true); - - wp_localize_script('octavawms-admin-connect', 'octavawmsConnect', [ - 'ajaxUrl' => admin_url('admin-ajax.php'), - 'nonce' => wp_create_nonce(self::ACTION), - 'panelLoginNonce' => wp_create_nonce(self::PANEL_LOGIN_NONCE_ACTION), - 'strings' => [ - 'connected' => __('Connected to OctavaWMS', 'octavawms'), - 'notConnected' => __('Not connected', 'octavawms'), - 'error' => __('Connect request failed. Check your site can reach the OctavaWMS service.', 'octavawms'), - 'panelLogin' => __('Login to the panel', 'octavawms'), - 'panelLoginError' => __('Could not open Octava panel. Try connecting again or check logs.', 'octavawms'), - ], - ]); - - wp_enqueue_script('octavawms-admin-connect'); - $matrixRel = 'assets/js/admin-settings-matrix.js'; $matrixPath = $plugin_root . '/' . $matrixRel; $matrixUrl = plugins_url($matrixRel, $plugin_root . '/octavawms-woocommerce.php'); @@ -122,7 +110,33 @@ public function maybeEnqueueConnectScript(string $hook): void 'anyRate' => __('— Any / none —', 'octavawms'), ], ]); - wp_enqueue_script('octavawms-admin-settings-matrix'); + + wp_register_script( + 'octavawms-admin-connect', + $script_url, + ['jquery', 'octavawms-admin-settings-matrix'], + $version, + true + ); + + wp_localize_script('octavawms-admin-connect', 'octavawmsConnect', [ + 'ajaxUrl' => admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce(self::ACTION), + 'panelLoginNonce' => wp_create_nonce(self::PANEL_LOGIN_NONCE_ACTION), + 'connectivityProbeAction' => self::CONNECTIVITY_PROBE_ACTION, + 'connectivityProbeNonce' => wp_create_nonce(self::CONNECTIVITY_PROBE_ACTION), + 'strings' => [ + 'connected' => __('Connected to OctavaWMS', 'octavawms'), + 'notConnected' => __('Not connected', 'octavawms'), + 'error' => __('Connect request failed. Check your site can reach the OctavaWMS service.', 'octavawms'), + 'panelLogin' => __('Login to the panel', 'octavawms'), + 'panelLoginError' => __('Could not open Octava panel. Try connecting again or check logs.', 'octavawms'), + 'connectivityRunning' => __('Running diagnostics…', 'octavawms'), + 'connectivityFailed' => __('Diagnostics request failed.', 'octavawms'), + ], + ]); + + wp_enqueue_script('octavawms-admin-connect'); } public function handleAjaxConnect(): void @@ -293,6 +307,32 @@ public function handleAjaxPanelLoginUrl(): void wp_send_json_success(['loginUrl' => $loginUrl]); } + public function handleAjaxConnectivityProbe(): void + { + if (! current_user_can('manage_woocommerce')) { + wp_send_json_error(['message' => __('You do not have permission.', 'octavawms')], 403); + } + + check_ajax_referer(self::CONNECTIVITY_PROBE_ACTION, 'security'); + + if (! function_exists('curl_init')) { + wp_send_json_error(['message' => __('PHP curl extension is required for diagnostics.', 'octavawms')], 500); + } + + $baseUrl = Options::getBaseUrl(); + if ($baseUrl === '') { + $baseUrl = Options::DEFAULT_API_BASE; + } + + $report = ConnectivityProbe::buildFullDiagnosticsReport( + $baseUrl, + ConnectivityProbe::DEFAULT_CONNECT_TIMEOUT, + ConnectivityProbe::DEFAULT_MAX_TIME + ); + + wp_send_json_success(['report' => $report]); + } + public const CONNECT_PATH = '/apps/woocommerce/connect'; private function getConnectUrlFromForm(): string diff --git a/src/SettingsPage.php b/src/SettingsPage.php index 27233ae..b9daa94 100644 --- a/src/SettingsPage.php +++ b/src/SettingsPage.php @@ -168,6 +168,7 @@ public function admin_options(): void echo $this->getConnectDescriptionHtml(); parent::admin_options(); echo $this->getCarrierMatrixSectionHtml(); + echo $this->getConnectivityDiagnosticsSectionHtml(); } private function getCarrierMatrixSectionHtml(): string @@ -228,4 +229,38 @@ private function getCarrierMatrixSectionHtml(): string return (string) ob_get_clean(); } + + private function getConnectivityDiagnosticsSectionHtml(): string + { + if (! current_user_can('manage_woocommerce')) { + return ''; + } + + ob_start(); + ?> +
+

+ +

+

+ +

+

+ + +

+

+        
+ Date: Wed, 13 May 2026 18:31:33 +0100 Subject: [PATCH 2/2] Enhance AJAX handling and error reporting in WooCommerce integration - Prevent duplicate AJAX action registrations in ConnectService, LabelAjax, and SettingsAjax classes. - Introduce robust error handling and user feedback for AJAX requests in admin-settings-matrix.js. - Ensure WooCommerce integrations are only attached once to avoid conflicts during bootstrap. - Improve the registration of AJAX hooks to handle cases where they may not be set due to load order issues. Co-authored-by: Cursor --- assets/js/admin-settings-matrix.js | 246 +++++++++++++++++++++++------ octavawms-woocommerce.php | 63 +++++++- src/Admin/LabelAjax.php | 3 + src/Admin/SettingsAjax.php | 24 ++- src/ConnectService.php | 19 ++- 5 files changed, 295 insertions(+), 60 deletions(-) diff --git a/assets/js/admin-settings-matrix.js b/assets/js/admin-settings-matrix.js index 6b0a673..5f59b4a 100644 --- a/assets/js/admin-settings-matrix.js +++ b/assets/js/admin-settings-matrix.js @@ -13,7 +13,120 @@ return window.octavawmsCarrierMatrix || {}; } + function matrixStrings() { + return cfg().strings || {}; + } + + function matrixAjaxConfigOk() { + var c = cfg(); + return !!(c.ajaxUrl && c.action && c.nonce); + } + + function rejectMissingMatrixAjaxConfig() { + var msg = + matrixStrings().ajaxMissingConfig || + 'Carrier matrix script configuration is missing. Reload the page or clear your cache.'; + var fakeXhr = { + status: 0, + responseText: '', + responseJSON: { data: { message: msg } } + }; + var d = $.Deferred(); + d.reject(fakeXhr, 'error', msg); + return d.promise(); + } + + function appendAjaxDebug(base, xhr, textStatus) { + if (!cfg().detailAjaxErrors) { + return base; + } + var status = xhr && xhr.status != null ? xhr.status : ''; + var raw = xhr && xhr.responseText != null ? String(xhr.responseText).trim() : ''; + var first = raw ? raw.split(/\r?\n/)[0] : ''; + if (first.length > 200) { + first = first.slice(0, 200) + '…'; + } + var bits = []; + if (status !== '') { + bits.push('HTTP ' + status); + } + if (textStatus) { + bits.push(textStatus); + } + if (first) { + bits.push(first); + } + return bits.length ? base + ' [' + bits.join(' | ') + ']' : base; + } + + /** + * Maps admin-ajax failures (including WordPress bare "0" + 400) to a readable message. + * + * @param {JQuery.jqXHR|null} xhr + * @param {string} textStatus + * @returns {string} + */ + function formatMatrixAjaxError(xhr, textStatus) { + var s = matrixStrings(); + if (textStatus === 'parsererror') { + return appendAjaxDebug(s.ajaxParseError || s.saveFailed || 'Error', xhr, textStatus); + } + var wpJsonMsg = null; + if (xhr && xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) { + wpJsonMsg = String(xhr.responseJSON.data.message); + } else if (xhr && xhr.responseText) { + try { + var parsed = JSON.parse(String(xhr.responseText)); + if (parsed && parsed.data && parsed.data.message) { + wpJsonMsg = String(parsed.data.message); + } + } catch (ignore) { + /* not JSON */ + } + } + if (wpJsonMsg) { + return appendAjaxDebug(wpJsonMsg, xhr, textStatus); + } + var status = xhr && xhr.status != null ? xhr.status : 0; + var body = xhr && xhr.responseText != null ? String(xhr.responseText).trim() : ''; + if (status === 400 && body === '0') { + return appendAjaxDebug(s.ajaxNoHandler || s.saveFailed || 'Error', xhr, textStatus); + } + if (status === 403 && (body === '-1' || body === '0')) { + return appendAjaxDebug(s.ajaxNonceExpired || s.saveFailed || 'Error', xhr, textStatus); + } + if (status >= 400) { + return appendAjaxDebug(s.ajaxHttpError || s.saveFailed || 'Error', xhr, textStatus); + } + return appendAjaxDebug(s.ajaxHttpError || s.saveFailed || 'Error', xhr, textStatus); + } + + function setSpinner(on) { + var $sp = $('#octavawms-matrix-spinner'); + if (!$sp.length) { + return; + } + $sp.css('visibility', on ? 'visible' : 'hidden'); + if (on) { + $sp.addClass('is-active'); + } else { + $sp.removeClass('is-active'); + } + } + + function setMessage(text, isError) { + var $m = $('#octavawms-matrix-message'); + if (!$m.length) { + return; + } + $m.text(text || ''); + $m.css('color', isError ? '#b32d2d' : '#1e4620'); + } + function post(subaction, extra) { + if (!matrixAjaxConfigOk()) { + return rejectMissingMatrixAjaxConfig(); + } var data = $.extend( { action: cfg().action, @@ -79,7 +192,7 @@ $('
').text(ds).html() + '" data-initial-label="">' + '' + '' + '' @@ -109,23 +222,27 @@ var $rate = $tr.find('.octavawms-rate'); $rate.empty(); $rate.append( - $('